LCOV - code coverage report
Current view: top level - src/jamidht - collaborative_editing.cpp (source / functions) Coverage Total Hit
Test: jami-coverage-filtered.info Lines: 76.0 % 890 676
Test Date: 2026-08-23 08:52:56 Functions: 95.5 % 67 64

            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           18 : truncatedName(std::string name)
     159              : {
     160           18 :     if (name.size() <= MAX_DOCUMENT_NAME_SIZE)
     161           18 :         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          168 : CollaborativeEditing::CollaborativeEditing(const std::shared_ptr<JamiAccount>& account)
     247          168 :     : account_(account)
     248          168 :     , accountId_(account->getAccountID())
     249          336 :     , ioContext_(Manager::instance().ioContext())
     250          168 : {}
     251              : 
     252          168 : CollaborativeEditing::~CollaborativeEditing() = default;
     253              : 
     254              : uint64_t
     255           33 : 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           33 :     if (clientId_ != 0)
     269            2 :         return clientId_;
     270           31 :     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           31 :     std::uniform_int_distribution<uint64_t> dist(1, (uint64_t(1) << 53) - 1);
     274           31 :     std::mt19937_64 gen(rd());
     275           31 :     clientId_ = dist(gen);
     276           31 :     return clientId_;
     277           31 : }
     278              : 
     279              : std::string
     280          193 : CollaborativeEditing::key(const std::string& conversationId, const std::string& documentId)
     281              : {
     282          193 :     return conversationId + '/' + documentId;
     283              : }
     284              : 
     285              : std::shared_ptr<Conversation>
     286          314 : CollaborativeEditing::documentConversation(const std::string& documentId)
     287              : {
     288          314 :     auto account = account_.lock();
     289          314 :     if (!account)
     290            0 :         return nullptr;
     291          314 :     auto* cm = account->convModule(true);
     292          314 :     if (!cm)
     293            0 :         return nullptr;
     294          314 :     auto conversation = cm->getConversation(documentId);
     295          314 :     if (!conversation || conversation->mode() != ConversationMode::DOCUMENT)
     296           63 :         return nullptr;
     297          251 :     return conversation;
     298          314 : }
     299              : 
     300              : std::shared_ptr<CollaborativeEditing::Session>
     301           57 : CollaborativeEditing::findSession(const std::string& conversationId, const std::string& documentId)
     302              : {
     303           57 :     std::lock_guard<std::mutex> lk(mutex_);
     304           57 :     auto it = sessions_.find(key(conversationId, documentId));
     305          114 :     return it != sessions_.end() ? it->second : nullptr;
     306           57 : }
     307              : 
     308              : std::shared_ptr<CollaborativeEditing::Session>
     309           23 : CollaborativeEditing::findSessionByDocument(const std::string& documentId)
     310              : {
     311           23 :     std::lock_guard<std::mutex> lk(mutex_);
     312           23 :     for (const auto& [_, session] : sessions_)
     313           23 :         if (session && session->documentId == documentId)
     314           23 :             return session;
     315            0 :     return nullptr;
     316           23 : }
     317              : 
     318              : std::shared_ptr<CollaborativeEditing::Session>
     319           37 : CollaborativeEditing::ensureSession(const std::string& conversationId, const std::string& documentId)
     320              : {
     321           37 :     std::lock_guard<std::mutex> lk(mutex_);
     322           37 :     auto k = key(conversationId, documentId);
     323           37 :     if (auto it = sessions_.find(k); it != sessions_.end())
     324            4 :         return it->second;
     325              : 
     326           33 :     auto session = std::make_shared<Session>();
     327           33 :     session->conversationId = conversationId;
     328           33 :     session->documentId = documentId;
     329           33 :     session->doc = std::make_unique<YrsDocument>(replicaId());
     330           33 :     session->checkpointTimer = std::make_unique<asio::steady_timer>(*ioContext_);
     331           33 :     session->awarenessTimer = std::make_unique<asio::steady_timer>(*ioContext_);
     332           33 :     sessions_.emplace(k, session);
     333           33 :     return session;
     334           37 : }
     335              : 
     336              : std::string
     337           16 : 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           16 :     const std::string type = mimeType.empty() ? DEFAULT_DOC_MIME_TYPE : mimeType;
     346              : 
     347           16 :     auto account = account_.lock();
     348           16 :     if (!account)
     349            0 :         return {};
     350           16 :     auto* cm = account->convModule();
     351           16 :     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           16 :     auto documentId = cm->startDocument(conversationId, type);
     356           16 :     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           16 :     const auto stored = truncatedName(name);
     365           16 :     if (!stored.empty())
     366           48 :         cm->updateConversationInfos(documentId, {{"title", stored}}, false);
     367              : 
     368           16 :     auto session = ensureSession(conversationId, documentId);
     369              :     {
     370           16 :         std::lock_guard<std::mutex> lk(mutex_);
     371           16 :         session->persistedLoaded = true; // the repository is newborn: nothing to replay
     372           16 :         session->announcedName = stored;
     373           16 :         nameCache_[key(conversationId, documentId)] = stored;
     374           16 :         ++nameEpoch_;
     375           16 :     }
     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           16 :         std::lock_guard<std::mutex> lk(announcedMtx_);
     381           16 :         announced_[conversationId].emplace(documentId);
     382           16 :     }
     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           16 :     auto announceResult = std::make_shared<std::promise<bool>>();
     391           16 :     auto announcedDone = announceResult->get_future();
     392           48 :     cm->createCommit(conversationId,
     393           32 :                      CommitMessage::collabDocCreated(documentId, stored, type),
     394              :                      true,
     395              :                      {},
     396           48 :                      [w = weak_from_this(),
     397              :                       conversationId,
     398              :                       documentId,
     399           16 :                       accountId = accountId_,
     400              :                       announceResult](bool ok, const std::string&) {
     401           16 :                          announceResult->set_value(ok);
     402           16 :                          if (ok)
     403           16 :                              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           16 :     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            0 :         JAMI_ERROR("[Account {}] Document {} announcement never completed in conversation {}",
     434              :                    accountId_,
     435              :                    documentId,
     436              :                    conversationId);
     437            0 :         return {};
     438              :     }
     439           16 :     if (!announcedDone.get())
     440            0 :         return {};
     441           16 :     return documentId;
     442           32 : }
     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            2 : CollaborativeEditing::removeDocumentLocally(const std::string& conversationId, const std::string& documentId)
     484              : {
     485            2 :     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            2 :     if (auto account = account_.lock())
     495            2 :         if (auto* cm = account->convModule())
     496            2 :             cm->removeDocumentReplica(documentId);
     497            2 :     dropLocalReplica(conversationId, documentId);
     498            2 :     emitSignal<libjami::ConversationSignal::CollaborativeDocumentRemoved>(accountId_, conversationId, documentId, false);
     499            2 :     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            2 : 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            2 :     const auto k = key(conversationId, documentId);
     547            2 :     uint64_t epoch = 0;
     548              :     {
     549            2 :         std::lock_guard<std::mutex> lk(mutex_);
     550            2 :         if (auto it = nameCache_.find(k); it != nameCache_.end())
     551            2 :             return it->second;
     552            0 :         epoch = nameEpoch_;
     553            2 :     }
     554            0 :     auto conversation = documentConversation(documentId);
     555            0 :     if (!conversation)
     556            0 :         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           75 : CollaborativeEditing::documents(const std::string& conversationId)
     572              : {
     573           75 :     auto account = account_.lock();
     574           75 :     if (!account)
     575            0 :         return {};
     576           75 :     auto* cm = account->convModule();
     577           75 :     if (!cm)
     578            0 :         return {};
     579           75 :     auto conversation = cm->getConversation(conversationId);
     580           75 :     if (!conversation)
     581           17 :         return {};
     582           58 :     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          101 :     for (auto& doc : docs) {
     588           43 :         auto it = doc.find("id");
     589          129 :         doc[DOCUMENT_STORED_LOCALLY] = (it != doc.end() && documentConversation(it->second)) ? TRUE_STR : FALSE_STR;
     590              :     }
     591           58 :     return docs;
     592           75 : }
     593              : 
     594              : bool
     595           23 : 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           23 :         std::lock_guard<std::mutex> lk(announcedMtx_);
     602           23 :         if (auto rmIt = removed_.find(conversationId); rmIt != removed_.end() && rmIt->second.count(documentId) != 0)
     603            0 :             return false;
     604           23 :         if (auto it = announced_.find(conversationId); it != announced_.end())
     605           23 :             return it->second.count(documentId) != 0;
     606           23 :     }
     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          149 : CollaborativeEditing::knowsDocument(const std::string& documentId)
     626              : {
     627          149 :     std::lock_guard<std::mutex> lk(announcedMtx_);
     628          149 :     for (const auto& [_, ids] : announced_)
     629            0 :         if (ids.count(documentId) != 0)
     630            0 :             return true;
     631          149 :     return false;
     632          149 : }
     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            5 : CollaborativeEditing::dropLocalReplica(const std::string& conversationId, const std::string& documentId)
     644              : {
     645            5 :     std::shared_ptr<Session> session;
     646              :     {
     647            5 :         std::lock_guard<std::mutex> lk(mutex_);
     648            5 :         auto k = key(conversationId, documentId);
     649            5 :         if (auto it = sessions_.find(k); it != sessions_.end()) {
     650            4 :             session = it->second;
     651            4 :             sessions_.erase(it);
     652              :         }
     653            5 :         nameCache_.erase(k);
     654            5 :         ++nameEpoch_;
     655            5 :     }
     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            5 :     if (session && session->checkpointTimer) {
     661            4 :         std::lock_guard<std::mutex> lk(session->timerMutex);
     662            4 :         session->checkpointTimer->cancel();
     663            4 :     }
     664            5 :     if (session)
     665            4 :         closeRealtimeChannels(session);
     666            5 : }
     667              : 
     668              : YrsDocument::Bytes
     669           21 : CollaborativeEditing::openDocument(const std::string& conversationId, const std::string& documentId)
     670              : {
     671              :     // A document only exists once the conversation announced it. Opening one
     672              :     // that was never announced would clone from any id a caller cares to name,
     673              :     // and bypass the very gate that decides which documents exist here.
     674           21 :     if (!isAnnouncedDocument(conversationId, documentId)) {
     675            0 :         JAMI_WARNING("[Account {}] Refusing to open document {}: it was not announced in conversation {}",
     676              :                      accountId_,
     677              :                      documentId,
     678              :                      conversationId);
     679            0 :         return {};
     680              :     }
     681           21 :     auto account = account_.lock();
     682           21 :     if (!account)
     683            0 :         return {};
     684           21 :     auto* cm = account->convModule();
     685           21 :     if (!cm)
     686            0 :         return {};
     687           21 :     auto conversation = documentConversation(documentId);
     688           21 :     if (!conversation) {
     689              :         // Opening is what opts this device into holding a replica: clone the
     690              :         // document's swarm from its announcer. The clone lands asynchronously
     691              :         // -- through the very pipeline a conversation invite uses -- and
     692              :         // reports through onRepositoryUpdated(), which replays it into this
     693              :         // session and hands the client the difference. Until then the document
     694              :         // is open and empty, exactly like a conversation still syncing.
     695           18 :         std::string announcer;
     696           18 :         std::string announcedName;
     697           18 :         for (const auto& doc : documents(conversationId)) {
     698           18 :             auto idIt = doc.find("id");
     699           18 :             if (idIt == doc.end() || idIt->second != documentId)
     700            0 :                 continue;
     701           36 :             if (auto authorIt = doc.find("author"); authorIt != doc.end())
     702           18 :                 announcer = authorIt->second;
     703           36 :             if (auto nameIt = doc.find("displayName"); nameIt != doc.end())
     704           18 :                 announcedName = nameIt->second;
     705           18 :             break;
     706           18 :         }
     707           18 :         if (announcer.empty()) {
     708            0 :             JAMI_WARNING("[Account {}] Unable to open document {}: its announcer is unknown", accountId_, documentId);
     709            0 :             return {};
     710              :         }
     711           18 :         auto session = ensureSession(conversationId, documentId);
     712              :         {
     713              :             // Nothing on disk yet, so nothing to replay; flagging it now is
     714              :             // what lets the clone's completion replay into this session.
     715           18 :             std::lock_guard<std::mutex> lk(mutex_);
     716           18 :             session->persistedLoaded = true;
     717           18 :             session->open = true;
     718              :             // The client saw the announcement's name; recording it is what lets
     719              :             // a rename that lands with (or after) the clone be seen as one.
     720           18 :             if (!session->announcedName)
     721           17 :                 session->announcedName = announcedName;
     722           18 :         }
     723              :         // The announcer is the likeliest holder, but no peer is a reliable
     724              :         // one: it may be offline, or be this very account -- its creator
     725              :         // reopening after a leave took the replica away, and only the
     726              :         // account's other devices can then serve it. Which members hold a
     727              :         // copy cannot be known without the repository, so every joined
     728              :         // member is a candidate; the module fetches from a couple and lets
     729              :         // its fallback rounds walk the rest.
     730           54 :         std::vector<std::string> candidates {announcer};
     731           54 :         for (const auto& member : cm->getConversationMembers(conversationId)) {
     732           36 :             auto uriIt = member.find("uri");
     733           36 :             if (uriIt == member.end() || uriIt->second == announcer)
     734           18 :                 continue;
     735              :             // Invited, banned and left members cannot hold a replica:
     736              :             // holding one starts with an open, which they are refused.
     737           18 :             auto roleIt = member.find("role");
     738           18 :             if (roleIt == member.end() || (roleIt->second != "admin" && roleIt->second != "member"))
     739            0 :                 continue;
     740           18 :             candidates.emplace_back(uriIt->second);
     741           18 :         }
     742              :         // The parent swarm already knows who is reachable right now: members
     743              :         // with a connected device come first, so the initial fetches go to
     744              :         // peers that can actually answer. Ties keep the announcer in front
     745              :         // as the likeliest holder.
     746           18 :         if (auto parent = cm->getConversation(conversationId)) {
     747           18 :             std::set<std::string> online;
     748           36 :             for (const auto& device : parent->peersToSyncWith()) {
     749           18 :                 auto uri = parent->uriFromDevice(device.toString());
     750           18 :                 if (!uri.empty())
     751           18 :                     online.emplace(std::move(uri));
     752           36 :             }
     753           18 :             std::stable_partition(candidates.begin(), candidates.end(), [&](const auto& uri) {
     754           36 :                 return online.count(uri) != 0;
     755              :             });
     756           36 :         }
     757           18 :         cm->cloneDocumentFrom(documentId, candidates);
     758              :         // No channels yet: they need the members recorded in the repository, so
     759              :         // the clone's completion is what opens them. Until then the document is
     760              :         // open and empty, exactly like a conversation still syncing.
     761           18 :         return session->doc->encodeStateAsUpdate();
     762           18 :     }
     763            3 :     auto session = ensureSession(conversationId, documentId);
     764              :     // Rebuild the CRDT state from persisted commits if this session was just created,
     765              :     // so a document opens with its full content even when the daemon restarted or the
     766              :     // commits were never replayed yet.
     767            3 :     loadPersistedState(session);
     768              :     // Remember the name the client is about to see, so a later rename can be
     769              :     // told from it.
     770              :     {
     771            3 :         auto infos = conversation->infos();
     772            3 :         auto it = infos.find("title");
     773            3 :         const auto name = it != infos.end() ? it->second : std::string {};
     774            3 :         std::lock_guard<std::mutex> lk(mutex_);
     775            3 :         session->open = true;
     776            3 :         if (!session->announcedName)
     777            0 :             session->announcedName = name;
     778            3 :     }
     779            3 :     auto state = session->doc->encodeStateAsUpdate();
     780              :     // Reach out to the other devices editing the document. What they produced
     781              :     // while nothing was open here is not asked for -- there is no handshake --
     782              :     // and does not need to be: it arrives with its producer's next checkpoint,
     783              :     // while everything from here on arrives live.
     784            3 :     connectRealtimeChannels(session);
     785            3 :     return state;
     786           39 : }
     787              : 
     788              : void
     789            8 : CollaborativeEditing::closeDocument(const std::string& conversationId, const std::string& documentId)
     790              : {
     791            8 :     auto session = findSession(conversationId, documentId);
     792            8 :     if (!session)
     793            0 :         return;
     794              :     {
     795            8 :         std::lock_guard<std::mutex> lk(mutex_);
     796            8 :         session->open = false;
     797            8 :     }
     798              :     // Flush pending edits, but keep the in-memory CRDT replica so that reopening
     799              :     // the document shows its current content. The session stays consistent via
     800              :     // persisted commits (replayed on load) and live updates from other members.
     801            8 :     if (session->checkpointTimer) {
     802            8 :         std::lock_guard<std::mutex> lk(session->timerMutex);
     803            8 :         session->checkpointTimer->cancel();
     804            8 :     }
     805            8 :     checkpointNow(session);
     806              :     // Withdraw this device's awareness state, which is what clears its cursor
     807              :     // for the other members. Done explicitly rather than left to expire so that
     808              :     // closing an editor is seen at once instead of a timeout later.
     809            8 :     publishAwareness(session, {});
     810              :     // The channels only exist between devices that are editing, and this one no
     811              :     // longer is. A reopen starts them over.
     812            8 :     closeRealtimeChannels(session);
     813            8 : }
     814              : 
     815              : void
     816            6 : CollaborativeEditing::applyUpdate(const std::string& conversationId,
     817              :                                   const std::string& documentId,
     818              :                                   const YrsDocument::Bytes& update)
     819              : {
     820            6 :     auto session = findSession(conversationId, documentId);
     821            6 :     if (!session)
     822            0 :         return;
     823            6 :     if (update.size() > MAX_UPDATE_SIZE) {
     824            0 :         JAMI_WARNING("[Account {}] [Document {}] Discarding a {} byte update from the client: "
     825              :                      "over the {} byte limit",
     826              :                      accountId_,
     827              :                      documentId,
     828              :                      update.size(),
     829              :                      MAX_UPDATE_SIZE);
     830            0 :         return;
     831              :     }
     832              :     // Merge before forwarding: an update the engine rejects must not be sent to
     833              :     // the members nor written to the repository.
     834            6 :     if (!session->doc->applyUpdate(update))
     835            0 :         return;
     836            6 :     onLocalUpdate(session, update);
     837            6 : }
     838              : 
     839              : YrsDocument::Bytes
     840            5 : CollaborativeEditing::documentState(const std::string& conversationId, const std::string& documentId)
     841              : {
     842            5 :     auto session = findSession(conversationId, documentId);
     843           10 :     return session ? session->doc->encodeStateAsUpdate() : YrsDocument::Bytes {};
     844            5 : }
     845              : 
     846              : namespace {
     847              : 
     848              : /// Frame a payload and write it to one channel.
     849              : void
     850            3 : writeFrame(const std::shared_ptr<dhtnet::ChannelSocket>& socket, uint8_t tag, const std::vector<uint8_t>& payload)
     851              : {
     852            3 :     std::vector<uint8_t> frame;
     853            3 :     frame.reserve(payload.size() + 11);
     854            3 :     appendVarUint(frame, payload.size());
     855            3 :     frame.push_back(tag);
     856            3 :     frame.insert(frame.end(), payload.begin(), payload.end());
     857            3 :     std::error_code ec;
     858            3 :     socket->write(frame.data(), frame.size(), ec);
     859            3 :     if (ec)
     860            0 :         JAMI_WARNING("Unable to send a {} byte collaborative frame: {}", frame.size(), ec.message());
     861            3 : }
     862              : 
     863              : } // namespace
     864              : 
     865              : bool
     866           17 : CollaborativeEditing::acceptsRealtimeChannel(const std::string& documentId,
     867              :                                              const std::string& peer,
     868              :                                              const std::string& deviceId)
     869              : {
     870              :     // Only for a document being edited here. A closed holder replicates through
     871              :     // the swarm and has no use for live frames; a device that never held the
     872              :     // document has nothing to accept them into; and answering for ids nobody
     873              :     // opened would let a peer probe what this device knows.
     874           17 :     auto session = findSessionByDocument(documentId);
     875           17 :     if (!session)
     876            0 :         return false;
     877              :     {
     878           17 :         std::lock_guard<std::mutex> lk(mutex_);
     879           17 :         if (!session->open)
     880           14 :             return false;
     881           17 :     }
     882              :     // Membership per the document's own replica, never the parent
     883              :     // conversation's. Invited counts as in: a document member is only ever
     884              :     // invited by a holder having just vouched for it while serving its clone,
     885              :     // and its join follows by itself -- there is no pending-invitation state to
     886              :     // wrongly admit. A joiner whose commits have not reached us at all is still
     887              :     // refused, and reached out to the moment they are merged.
     888            3 :     auto conversation = documentConversation(documentId);
     889            3 :     if (!conversation)
     890            0 :         return false; // the clone is still in flight: nothing to check against
     891            3 :     return conversation->isPeerAuthorized(peer, deviceId, true);
     892           17 : }
     893              : 
     894              : void
     895            6 : CollaborativeEditing::onRealtimeChannel(const std::string& documentId,
     896              :                                         const std::string& peer,
     897              :                                         const std::string& deviceId,
     898              :                                         std::shared_ptr<dhtnet::ChannelSocket> socket)
     899              : {
     900            6 :     auto session = findSessionByDocument(documentId);
     901            6 :     bool open = false;
     902            6 :     if (session) {
     903            6 :         std::lock_guard<std::mutex> lk(mutex_);
     904            6 :         open = session->open;
     905            6 :     }
     906            6 :     if (!open) {
     907              :         // The document was closed while the channel was being set up.
     908            0 :         socket->shutdown();
     909            0 :         return;
     910              :     }
     911              :     {
     912            6 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
     913            6 :         session->channels[deviceId].push_back(socket);
     914            6 :     }
     915              : 
     916              :     // The parsing state of this one channel. Only its own receive callback
     917              :     // touches it, and those are delivered in order, so it needs no lock.
     918            6 :     auto buffer = std::make_shared<std::vector<uint8_t>>();
     919            6 :     std::weak_ptr<CollaborativeEditing> wthis = weak_from_this();
     920            6 :     std::weak_ptr<Session> wsession = session;
     921            6 :     std::weak_ptr<dhtnet::ChannelSocket> wsocket = socket;
     922            6 :     socket->setOnRecv([wthis, wsession, wsocket, peer, buffer](const uint8_t* data, size_t size) {
     923            3 :         auto sthis = wthis.lock();
     924            3 :         auto session = wsession.lock();
     925            3 :         if (!sthis || !session)
     926            0 :             return size;
     927            3 :         buffer->insert(buffer->end(), data, data + size);
     928            3 :         size_t pos = 0;
     929            6 :         while (pos < buffer->size()) {
     930            3 :             uint64_t length = 0;
     931            3 :             size_t consumed = 0;
     932            3 :             const auto res = readVarUint(buffer->data() + pos, buffer->size() - pos, length, consumed);
     933            3 :             if (res == VarUint::INCOMPLETE)
     934            0 :                 break;
     935              :             // The length is judged before anything is held against it: a peer
     936              :             // must not have us buffer megabytes of a frame that could only be
     937              :             // refused once complete. Nothing recovers a framing violation --
     938              :             // there is no way back into sync with a stream that lies about its
     939              :             // lengths -- so the channel goes down with it.
     940            3 :             if (res == VarUint::MALFORMED || length > MAX_UPDATE_SIZE) {
     941            0 :                 buffer->clear();
     942            0 :                 if (auto socket = wsocket.lock())
     943            0 :                     socket->shutdown();
     944            0 :                 return size;
     945              :             }
     946            3 :             if (buffer->size() - pos - consumed < 1 + length)
     947            0 :                 break; // the rest of the frame is still in flight
     948            3 :             const uint8_t tag = (*buffer)[pos + consumed];
     949            3 :             sthis->onFrame(session, peer, tag, buffer->data() + pos + consumed + 1, length);
     950            3 :             pos += consumed + 1 + length;
     951              :         }
     952            3 :         buffer->erase(buffer->begin(), buffer->begin() + pos);
     953            3 :         return size;
     954            3 :     });
     955            6 :     socket->onShutdown([wsession, wsocket, deviceId](const std::error_code& /*ec*/) {
     956            6 :         auto session = wsession.lock();
     957            6 :         if (!session)
     958            0 :             return;
     959            6 :         auto socket = wsocket.lock();
     960            6 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
     961            6 :         auto it = session->channels.find(deviceId);
     962            6 :         if (it == session->channels.end())
     963            1 :             return;
     964            5 :         auto& list = it->second;
     965           10 :         list.erase(std::remove_if(list.begin(), list.end(), [&](const auto& s) { return !socket || s == socket; }),
     966            5 :                    list.end());
     967            5 :         if (list.empty())
     968            5 :             session->channels.erase(it);
     969            8 :     });
     970              : 
     971              :     // Tell the newcomer at once who this device is in the document: our
     972              :     // awareness state only re-announces itself at the renewal cadence, and an
     973              :     // editor joining a session should not stare at an empty document for
     974              :     // fifteen seconds before the cursors appear.
     975            6 :     std::vector<uint8_t> hello;
     976              :     {
     977            6 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
     978            6 :         if (!session->localState.empty())
     979            0 :             hello = encodeAwareness({{clientId_, session->localClock, session->localState}});
     980            6 :     }
     981            6 :     if (!hello.empty())
     982            0 :         writeFrame(socket, FRAME_AWARENESS, hello);
     983            6 : }
     984              : 
     985              : void
     986           39 : CollaborativeEditing::connectRealtimeChannels(const std::shared_ptr<Session>& session)
     987              : {
     988           39 :     auto account = account_.lock();
     989           39 :     if (!account)
     990            0 :         return;
     991              :     {
     992           39 :         std::lock_guard<std::mutex> lk(mutex_);
     993           39 :         if (!session->open)
     994           14 :             return;
     995           39 :     }
     996           25 :     auto conversation = documentConversation(session->documentId);
     997           25 :     if (!conversation)
     998            0 :         return; // the clone is still in flight: its completion comes back here
     999              :     // Every device the repository lists is asked, not just the ones the
    1000              :     // document's swarm is connected to right now: the channel dials through the
    1001              :     // connection manager, which reaches a device the DRT has not settled on
    1002              :     // yet. Most are declined -- the other end only accepts while it has the
    1003              :     // document open -- and the ones that matter are the editors.
    1004           25 :     const auto ownDevice = std::string(account->currentDeviceId());
    1005           72 :     for (const auto& [member, devices] : conversation->memberDevices()) {
    1006           94 :         for (const auto& device : devices) {
    1007           47 :             const auto deviceId = device.toString();
    1008           47 :             if (deviceId == ownDevice)
    1009           24 :                 continue;
    1010              :             {
    1011           23 :                 std::lock_guard<std::mutex> lk(session->protocolMutex);
    1012           23 :                 if (session->channels.count(deviceId) != 0)
    1013            3 :                     continue; // already talking to it
    1014           23 :             }
    1015              :             // The socket is handled where the accepting side's is, in
    1016              :             // YdocChannelHandler::onReady; a refusal needs nothing done.
    1017           20 :             account->connectYdocDevice(device, session->documentId);
    1018           47 :         }
    1019           25 :     }
    1020           39 : }
    1021              : 
    1022              : void
    1023           12 : CollaborativeEditing::closeRealtimeChannels(const std::shared_ptr<Session>& session)
    1024              : {
    1025           12 :     decltype(session->channels) channels;
    1026              :     {
    1027           12 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
    1028           12 :         channels = std::move(session->channels);
    1029           12 :         session->channels.clear();
    1030           12 :     }
    1031              :     // Outside the lock: shutting a socket down runs its shutdown handler, which
    1032              :     // takes the same lock to unregister -- and finds nothing left to.
    1033           13 :     for (auto& [_, list] : channels)
    1034            2 :         for (auto& socket : list)
    1035            1 :             socket->shutdown();
    1036           12 : }
    1037              : 
    1038              : void
    1039            8 : CollaborativeEditing::broadcastFrame(const std::shared_ptr<Session>& session,
    1040              :                                      uint8_t tag,
    1041              :                                      const std::vector<uint8_t>& payload)
    1042              : {
    1043            8 :     if (payload.empty())
    1044            0 :         return;
    1045              :     // Written outside the lock: a send can stall on a congested peer, and the
    1046              :     // receive path needs the lock to route what the others are saying.
    1047            8 :     std::vector<std::shared_ptr<dhtnet::ChannelSocket>> sockets;
    1048              :     {
    1049            8 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
    1050           11 :         for (const auto& [_, list] : session->channels)
    1051            3 :             sockets.insert(sockets.end(), list.begin(), list.end());
    1052            8 :     }
    1053           11 :     for (const auto& socket : sockets)
    1054            3 :         writeFrame(socket, tag, payload);
    1055            8 : }
    1056              : 
    1057              : void
    1058            3 : CollaborativeEditing::onFrame(
    1059              :     const std::shared_ptr<Session>& session, const std::string& from, uint8_t tag, const uint8_t* payload, size_t size)
    1060              : {
    1061            3 :     switch (tag) {
    1062            1 :     case FRAME_UPDATE:
    1063            1 :         onRemoteUpdate(session, YrsDocument::Bytes(payload, payload + size));
    1064            1 :         break;
    1065            2 :     case FRAME_AWARENESS:
    1066            2 :         onAwarenessPayload(session, from, payload, size);
    1067            2 :         break;
    1068            0 :     default:
    1069              :         // A frame type from a newer daemon. Skipping it -- its length is known
    1070              :         // -- is what lets one be added without every peer upgrading first.
    1071            0 :         break;
    1072              :     }
    1073            3 : }
    1074              : 
    1075              : void
    1076            1 : CollaborativeEditing::onRemoteUpdate(const std::shared_ptr<Session>& session, const YrsDocument::Bytes& update)
    1077              : {
    1078            1 :     if (update.empty())
    1079            0 :         return;
    1080              :     // Cleared before the update rather than read after it alone, so that what
    1081              :     // this update brought is not confused with what an earlier one did.
    1082            1 :     session->doc->takeChanged();
    1083            1 :     if (!session->doc->applyUpdate(update))
    1084            0 :         return; // malformed: don't hand it to the clients
    1085              :     // Nothing when the update taught the replica nothing: a frame that raced a
    1086              :     // checkpoint fetch carries what the replay already merged, and forwarding
    1087              :     // it would light an "unread" badge on a document nobody touched.
    1088            1 :     if (!session->doc->takeChanged())
    1089            0 :         return;
    1090              :     // Channels only exist while the document is open here, but a frame can slip
    1091              :     // in between the close and the sockets going down; reopening hands the
    1092              :     // merged state over instead.
    1093              :     {
    1094            1 :         std::lock_guard<std::mutex> lk(mutex_);
    1095            1 :         if (!session->open)
    1096            0 :             return;
    1097            1 :     }
    1098              :     // Not persisted here: the device that produced it checkpoints it into its own
    1099              :     // repository and it reaches ours through synchronization. Storing it again
    1100              :     // would keep one copy per member of every single edit.
    1101            1 :     emitUpdate(session->conversationId, session->documentId, update);
    1102              : }
    1103              : 
    1104              : void
    1105            2 : CollaborativeEditing::onAwarenessPayload(const std::shared_ptr<Session>& session,
    1106              :                                          const std::string& from,
    1107              :                                          const uint8_t* data,
    1108              :                                          size_t size)
    1109              : {
    1110            2 :     if (size > MAX_AWARENESS_MESSAGE_SIZE)
    1111            0 :         return;
    1112            2 :     std::vector<AwarenessWire> entries;
    1113              :     try {
    1114            2 :         msgpack::object_handle oh = msgpack::unpack(reinterpret_cast<const char*>(data), size);
    1115            2 :         oh.get().convert(entries);
    1116            2 :     } catch (const std::exception&) {
    1117            0 :         return; // a peer sent something that is not an awareness message
    1118            0 :     }
    1119              : 
    1120              :     // What the local clients have to be told, gathered while the table is locked
    1121              :     // and emitted once it is not: a signal handler is entitled to call back into
    1122              :     // this manager.
    1123            2 :     std::vector<std::pair<uint64_t, std::string>> changed;
    1124            2 :     std::vector<uint64_t> left;
    1125            2 :     bool contested = false;
    1126            2 :     const auto now = std::chrono::steady_clock::now();
    1127              :     {
    1128            2 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
    1129            4 :         for (const auto& entry : entries) {
    1130            2 :             if (entry.state.size() > MAX_AWARENESS_SIZE)
    1131            0 :                 continue; // a presence state, not a payload
    1132            2 :             if (entry.clientId == clientId_) {
    1133              :                 // Someone is speaking for this device. Nothing legitimate does
    1134              :                 // that, and the protocol's own answer -- outrun it so that peers
    1135              :                 // keep our real state -- is also the right one here.
    1136            0 :                 contested = true;
    1137            0 :                 continue;
    1138              :             }
    1139            2 :             auto it = session->awareness.find(entry.clientId);
    1140            2 :             if (it != session->awareness.end()) {
    1141            1 :                 if (it->second.owner != from)
    1142            0 :                     continue; // a member may not speak for another's client id
    1143              :                 // Strictly greater: two states with the same clock are the same
    1144              :                 // state having taken two routes, and re-emitting one of them
    1145              :                 // would make a cursor jump back to where it already was.
    1146            1 :                 if (entry.clock <= it->second.clock)
    1147            0 :                     continue;
    1148              :             } else {
    1149            1 :                 if (session->awareness.size() >= MAX_AWARENESS_PEERS)
    1150            0 :                     continue;
    1151            1 :                 if (entry.state.empty() || entry.state == "null")
    1152            0 :                     continue; // a client that is gone and was never here
    1153              :             }
    1154            2 :             const bool gone = entry.state.empty() || entry.state == "null";
    1155            2 :             if (gone) {
    1156            1 :                 session->awareness.erase(entry.clientId);
    1157            1 :                 left.push_back(entry.clientId);
    1158              :             } else {
    1159            1 :                 auto& peer = session->awareness[entry.clientId];
    1160            1 :                 peer.clock = entry.clock;
    1161            1 :                 peer.state = entry.state;
    1162            1 :                 peer.lastSeen = now;
    1163            1 :                 peer.owner = from;
    1164            1 :                 changed.emplace_back(entry.clientId, entry.state);
    1165              :             }
    1166              :         }
    1167            2 :     }
    1168              : 
    1169            3 :     for (const auto& [clientId, state] : changed)
    1170            2 :         emitSignal<libjami::ConversationSignal::CollaborativeAwarenessChanged>(accountId_,
    1171            1 :                                                                                session->conversationId,
    1172            1 :                                                                                session->documentId,
    1173              :                                                                                from,
    1174              :                                                                                clientId,
    1175              :                                                                                state);
    1176            3 :     for (auto clientId : left)
    1177            2 :         emitSignal<libjami::ConversationSignal::CollaborativeParticipantLeft>(accountId_,
    1178            1 :                                                                               session->conversationId,
    1179            1 :                                                                               session->documentId,
    1180              :                                                                               from,
    1181              :                                                                               clientId);
    1182            2 :     if (contested) {
    1183            0 :         JAMI_WARNING("[Account {}] [Document {}] {} announced this device's client id; re-announcing",
    1184              :                      accountId_,
    1185              :                      session->documentId,
    1186              :                      from);
    1187            0 :         std::string state;
    1188              :         {
    1189            0 :             std::lock_guard<std::mutex> lk(session->protocolMutex);
    1190            0 :             state = session->localState;
    1191            0 :         }
    1192            0 :         publishAwareness(session, state);
    1193            0 :     }
    1194            2 :     scheduleAwarenessUpkeep(session);
    1195            2 : }
    1196              : 
    1197              : void
    1198            1 : CollaborativeEditing::setAwareness(const std::string& conversationId,
    1199              :                                    const std::string& documentId,
    1200              :                                    const std::string& state)
    1201              : {
    1202            1 :     if (state.size() > MAX_AWARENESS_SIZE) {
    1203            0 :         JAMI_WARNING("[Account {}] [Document {}] Refusing to broadcast an oversized awareness state",
    1204              :                      accountId_,
    1205              :                      documentId);
    1206            0 :         return;
    1207              :     }
    1208              :     // Only for a document this device has open: an awareness state is about
    1209              :     // where its editor is, and there is no editor before that.
    1210            1 :     if (auto session = findSession(conversationId, documentId))
    1211            1 :         publishAwareness(session, state);
    1212              : }
    1213              : 
    1214              : void
    1215            9 : CollaborativeEditing::publishAwareness(const std::shared_ptr<Session>& session, const std::string& state)
    1216              : {
    1217            9 :     AwarenessWire entry;
    1218              :     {
    1219            9 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
    1220              :         // Withdrawing a state that was never shared would tell the members about
    1221              :         // an editor they were never told about in the first place.
    1222            9 :         if (state.empty() && session->localState.empty())
    1223            7 :             return;
    1224            2 :         entry.clientId = clientId_;
    1225            2 :         entry.clock = ++session->localClock;
    1226              :         // "null" is how a client that is no longer there is spelled.
    1227            3 :         entry.state = state.empty() ? "null" : state;
    1228            2 :         session->localState = state;
    1229            2 :         session->localAnnounced = std::chrono::steady_clock::now();
    1230            9 :     }
    1231            6 :     broadcastFrame(session, FRAME_AWARENESS, encodeAwareness({entry}));
    1232            2 :     scheduleAwarenessUpkeep(session);
    1233           11 : }
    1234              : 
    1235              : void
    1236            4 : CollaborativeEditing::scheduleAwarenessUpkeep(const std::shared_ptr<Session>& session)
    1237              : {
    1238            4 :     if (!session->awarenessTimer)
    1239            0 :         return;
    1240              :     {
    1241            4 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
    1242              :         // One timer at a time, and none at all while nobody is editing: a
    1243              :         // document nobody has open must not keep waking the process up.
    1244            4 :         if (session->upkeepRunning)
    1245            2 :             return;
    1246            2 :         if (session->awareness.empty() && session->localState.empty())
    1247            0 :             return;
    1248            2 :         session->upkeepRunning = true;
    1249            2 :         session->awarenessTimer->expires_after(AWARENESS_SWEEP);
    1250            4 :     }
    1251            2 :     std::weak_ptr<CollaborativeEditing> wthis = weak_from_this();
    1252            2 :     std::weak_ptr<Session> wsession = session;
    1253            2 :     session->awarenessTimer->async_wait([wthis, wsession](const asio::error_code& ec) {
    1254            2 :         auto sthis = wthis.lock();
    1255            2 :         auto session = wsession.lock();
    1256            2 :         if (!sthis || !session)
    1257            2 :             return;
    1258              :         {
    1259            0 :             std::lock_guard<std::mutex> lk(session->protocolMutex);
    1260            0 :             session->upkeepRunning = false;
    1261            0 :         }
    1262            0 :         if (!ec)
    1263            0 :             sthis->awarenessUpkeep(session);
    1264            4 :     });
    1265            2 : }
    1266              : 
    1267              : void
    1268            0 : CollaborativeEditing::awarenessUpkeep(const std::shared_ptr<Session>& session)
    1269              : {
    1270            0 :     const auto now = std::chrono::steady_clock::now();
    1271            0 :     std::vector<std::pair<uint64_t, std::string>> expired;
    1272            0 :     std::string renew;
    1273              :     {
    1274            0 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
    1275            0 :         for (auto it = session->awareness.begin(); it != session->awareness.end();) {
    1276            0 :             if (now - it->second.lastSeen >= AWARENESS_TIMEOUT) {
    1277            0 :                 expired.emplace_back(it->first, it->second.owner);
    1278            0 :                 it = session->awareness.erase(it);
    1279              :             } else {
    1280            0 :                 ++it;
    1281              :             }
    1282              :         }
    1283              :         // Re-announced well before it would expire elsewhere, so that a single
    1284              :         // lost message does not make this device blink out of the document.
    1285            0 :         if (!session->localState.empty() && now - session->localAnnounced >= AWARENESS_RENEW)
    1286            0 :             renew = session->localState;
    1287            0 :     }
    1288            0 :     for (const auto& [clientId, owner] : expired)
    1289            0 :         emitSignal<libjami::ConversationSignal::CollaborativeParticipantLeft>(accountId_,
    1290            0 :                                                                               session->conversationId,
    1291            0 :                                                                               session->documentId,
    1292              :                                                                               owner,
    1293              :                                                                               clientId);
    1294            0 :     if (!renew.empty())
    1295            0 :         publishAwareness(session, renew); // rearms the timer on its way out
    1296              :     else
    1297            0 :         scheduleAwarenessUpkeep(session);
    1298            0 : }
    1299              : 
    1300              : void
    1301            6 : CollaborativeEditing::onLocalUpdate(const std::shared_ptr<Session>& session, const YrsDocument::Bytes& update)
    1302              : {
    1303              :     // Real-time path: hand the incremental update to the devices editing along.
    1304            6 :     broadcastFrame(session, FRAME_UPDATE, update);
    1305              :     // Durable path: accumulate the update for the next checkpoint.
    1306            6 :     queueUpdate(session, update);
    1307            6 : }
    1308              : 
    1309              : void
    1310           36 : CollaborativeEditing::replayStoredUpdates(const std::shared_ptr<Session>& session)
    1311              : {
    1312           36 :     auto conversation = documentConversation(session->documentId);
    1313           36 :     if (!conversation)
    1314            0 :         return;
    1315              :     // The updates come from peers' commits, so their content is not ours to
    1316              :     // trust: a malformed one must cost that one update, not the whole replay.
    1317           47 :     for (const auto& encoded : conversation->documentUpdates()) {
    1318              :         try {
    1319           11 :             session->doc->applyUpdate(base64::decode(encoded));
    1320            0 :         } catch (const std::exception& e) {
    1321            0 :             JAMI_WARNING("[Account {}] [Document {}] Skipping unreadable stored update: {}",
    1322              :                          accountId_,
    1323              :                          session->documentId,
    1324              :                          e.what());
    1325            0 :         }
    1326           36 :     }
    1327           36 : }
    1328              : 
    1329              : void
    1330            3 : CollaborativeEditing::loadPersistedState(const std::shared_ptr<Session>& session)
    1331              : {
    1332              :     {
    1333            3 :         std::lock_guard<std::mutex> lk(mutex_);
    1334            3 :         if (session->persistedLoaded)
    1335            3 :             return;
    1336            3 :     }
    1337              :     // Replay the updates stored in the document's repository. Nothing is
    1338              :     // signalled: the caller encodes the converged state and hands it to the
    1339              :     // client that asked to open the document.
    1340            0 :     replayStoredUpdates(session);
    1341              :     // What is already stored is not news: the client resolves the attachments
    1342              :     // of the state it is being handed. Only what arrives afterwards is signalled.
    1343              :     // The asymmetry is deliberate. For a document being opened the client pulls,
    1344              :     // asking for each reference it meets while rendering; announcing every stored
    1345              :     // attachment here would push bytes nobody has asked for yet, on a document the
    1346              :     // user may never scroll through. The signal exists for the opposite case: an
    1347              :     // attachment landing in an already open document, which the client has no
    1348              :     // reason to look for.
    1349            0 :     std::vector<std::string> ids;
    1350            0 :     if (auto conversation = documentConversation(session->documentId))
    1351            0 :         ids = conversation->documentAttachmentIds();
    1352              :     // Only now: a session flagged as loaded is never replayed again, so flagging
    1353              :     // it before the replay would freeze a partially rebuilt document.
    1354            0 :     std::lock_guard<std::mutex> lk(mutex_);
    1355            0 :     session->persistedLoaded = true;
    1356            0 :     for (auto& id : ids)
    1357            0 :         session->knownAttachments.insert(std::move(id));
    1358            0 : }
    1359              : 
    1360              : void
    1361            6 : CollaborativeEditing::queueUpdate(const std::shared_ptr<Session>& session, const YrsDocument::Bytes& update)
    1362              : {
    1363            6 :     const bool held = documentConversation(session->documentId) != nullptr;
    1364            6 :     bool capReached = false;
    1365              :     {
    1366            6 :         std::lock_guard<std::mutex> lk(session->pendingMutex);
    1367              :         // Without a repository nothing will ever drain this: keep the last batch
    1368              :         // so a repository appearing later still saves recent work, and drop the
    1369              :         // rest rather than growing without bound.
    1370            6 :         if (!held && session->pending.size() >= PENDING_HARD_CAP)
    1371            0 :             session->pending.erase(session->pending.begin(), session->pending.begin() + CHECKPOINT_MAX_PENDING);
    1372            6 :         session->pending.emplace_back(base64::encode(update));
    1373            6 :         capReached = session->pending.size() >= CHECKPOINT_MAX_PENDING;
    1374            6 :     }
    1375              :     // Checkpointing reads the document back, so it must never run inline on the
    1376              :     // path that just wrote to it: scheduleCheckpoint() only arms a timer.
    1377            6 :     if (capReached)
    1378            0 :         session->checkpointDue = true;
    1379            6 :     scheduleCheckpoint(session, capReached ? std::chrono::seconds(0) : CHECKPOINT_IDLE);
    1380            6 : }
    1381              : 
    1382              : void
    1383            6 : CollaborativeEditing::scheduleCheckpoint(const std::shared_ptr<Session>& session, std::chrono::seconds delay)
    1384              : {
    1385            6 :     if (!session->checkpointTimer)
    1386            0 :         return;
    1387              :     // Once the batch has reached its cap, stop letting new edits push the
    1388              :     // deadline further away: sustained typing would otherwise never checkpoint.
    1389            6 :     if (delay.count() > 0 && session->checkpointDue)
    1390            0 :         return;
    1391            6 :     std::weak_ptr<CollaborativeEditing> wthis = weak_from_this();
    1392            6 :     std::weak_ptr<Session> wsession = session;
    1393            6 :     std::lock_guard<std::mutex> lk(session->timerMutex);
    1394            6 :     session->checkpointTimer->expires_after(delay);
    1395            6 :     session->checkpointTimer->async_wait([wthis, wsession](const asio::error_code& ec) {
    1396            6 :         if (ec) // cancelled by a newer edit (debounce) or by shutdown
    1397            1 :             return;
    1398            5 :         auto sthis = wthis.lock();
    1399            5 :         auto session = wsession.lock();
    1400            5 :         if (sthis && session)
    1401            5 :             sthis->checkpointNow(session);
    1402            5 :     });
    1403            6 : }
    1404              : 
    1405              : void
    1406           42 : CollaborativeEditing::checkpointNow(const std::shared_ptr<Session>& session)
    1407              : {
    1408           42 :     session->checkpointDue = false;
    1409           42 :     auto account = account_.lock();
    1410           42 :     if (!account)
    1411            0 :         return;
    1412           42 :     auto* cm = account->convModule();
    1413           42 :     if (!cm)
    1414            0 :         return;
    1415              :     // Before draining anything: a drained batch with nowhere to go is lost.
    1416              :     // Keeping the batch queued means a replica appearing later -- the document
    1417              :     // being opened, which is what clones it -- still saves recent work.
    1418           42 :     if (!documentConversation(session->documentId))
    1419            5 :         return;
    1420           37 :     std::vector<std::string> batch;
    1421              :     {
    1422           37 :         std::lock_guard<std::mutex> lk(session->pendingMutex);
    1423           37 :         batch.swap(session->pending);
    1424           37 :     }
    1425           37 :     if (batch.empty())
    1426           31 :         return;
    1427              : 
    1428              :     // The checkpoint is a commit in the document's own swarm: committing it is
    1429              :     // also what announces it, so the other holders fetch it through the same
    1430              :     // pipeline that moves conversation messages. Nothing else needs sending.
    1431           18 :     cm->createCommit(session->documentId,
    1432           12 :                      CommitMessage::checkpoint(batch),
    1433              :                      true,
    1434              :                      {},
    1435           12 :                      [w = weak_from_this(), wsession = std::weak_ptr<Session>(session), batch](bool ok,
    1436              :                                                                                                const std::string&) {
    1437            6 :                          if (ok)
    1438            6 :                              return;
    1439              :                          // Keep the updates queued so the next checkpoint retries
    1440              :                          // them rather than silently losing the edits they carry,
    1441              :                          // and make sure a retry is actually scheduled even if the
    1442              :                          // user has stopped typing.
    1443            0 :                          auto sthis = w.lock();
    1444            0 :                          auto session = wsession.lock();
    1445            0 :                          if (!sthis || !session)
    1446            0 :                              return;
    1447              :                          {
    1448            0 :                              std::lock_guard<std::mutex> lk(session->pendingMutex);
    1449            0 :                              session->pending.insert(session->pending.begin(), batch.begin(), batch.end());
    1450            0 :                          }
    1451            0 :                          sthis->scheduleCheckpoint(session, CHECKPOINT_IDLE);
    1452            0 :                      });
    1453           73 : }
    1454              : 
    1455              : std::vector<std::map<std::string, std::string>>
    1456           61 : CollaborativeEditing::history(const std::string& /*conversationId*/, const std::string& documentId, size_t max)
    1457              : {
    1458           61 :     auto conversation = documentConversation(documentId);
    1459          122 :     return conversation ? conversation->documentHistory(max) : std::vector<std::map<std::string, std::string>> {};
    1460           61 : }
    1461              : 
    1462              : YrsDocument::Bytes
    1463            0 : CollaborativeEditing::documentStateAt(const std::string& /*conversationId*/,
    1464              :                                       const std::string& documentId,
    1465              :                                       const std::string& commitId)
    1466              : {
    1467            0 :     auto conversation = documentConversation(documentId);
    1468            0 :     if (!conversation)
    1469            0 :         return {};
    1470              : 
    1471              :     // Nothing at all when the checkpoint is unknown, which is what the public
    1472              :     // contract promises. It has to be told apart from a checkpoint that exists
    1473              :     // and holds nothing: the two would otherwise be the same answer, and a
    1474              :     // client restoring an early, legitimately empty version could not tell
    1475              :     // whether it was allowed to.
    1476            0 :     const auto stored = conversation->documentUpdatesAt(commitId);
    1477            0 :     if (!stored)
    1478            0 :         return {};
    1479              : 
    1480              :     // Replay into a throwaway replica: the live document must not be touched.
    1481              :     // What the client does with that state -- show it, restore it, diff it -- is
    1482              :     // its own business, and depends on a document type the daemon ignores.
    1483            0 :     YrsDocument snapshot {replicaId()};
    1484            0 :     for (const auto& encoded : *stored) {
    1485              :         try {
    1486            0 :             snapshot.applyUpdate(base64::decode(encoded));
    1487            0 :         } catch (const std::exception& e) {
    1488            0 :             JAMI_WARNING("[Account {}] [Document {}] Skipping unreadable stored update: {}",
    1489              :                          accountId_,
    1490              :                          documentId,
    1491              :                          e.what());
    1492            0 :         }
    1493              :     }
    1494            0 :     return snapshot.encodeStateAsUpdate();
    1495            0 : }
    1496              : 
    1497              : void
    1498           54 : CollaborativeEditing::onDocumentAnnounced(const std::string& conversationId, const std::string& documentId)
    1499              : {
    1500              :     // The author may have retired this announcement. Answered from the cache
    1501              :     // alone, never by walking the conversation again: this runs while
    1502              :     // addToHistory() holds the conversation lock, and asking the conversation
    1503              :     // anything from here is what deadlocks the caller. addToHistory() applies the
    1504              :     // removals of a batch before its announcements, and a removal is always newer
    1505              :     // than the announcement it retires, so the cache is already right by now.
    1506              :     //
    1507              :     // Nothing is replicated here: holding a replica is a per-device choice, made
    1508              :     // by opening the document. The announcement only records that it exists.
    1509           54 :     std::lock_guard<std::mutex> lk(announcedMtx_);
    1510           54 :     if (auto it = removed_.find(conversationId); it != removed_.end() && it->second.count(documentId) != 0)
    1511            0 :         return;
    1512           54 :     announced_[conversationId].emplace(documentId);
    1513           54 : }
    1514              : 
    1515              : void
    1516            3 : CollaborativeEditing::onDocumentRemoved(const std::string& conversationId, const std::string& documentId)
    1517              : {
    1518              :     {
    1519            3 :         std::lock_guard<std::mutex> lk(announcedMtx_);
    1520            3 :         removed_[conversationId].emplace(documentId);
    1521            3 :         if (auto it = announced_.find(conversationId); it != announced_.end())
    1522            3 :             it->second.erase(documentId);
    1523            3 :     }
    1524            3 :     dropLocalReplica(conversationId, documentId);
    1525              :     // The repository goes too: a document nobody can open again would otherwise
    1526              :     // outlive its own removal on every device that held it. From another
    1527              :     // thread: this runs while addToHistory() holds the parent conversation's
    1528              :     // lock, and tearing a conversation down takes locks of its own.
    1529            3 :     dht::ThreadPool::io().run([w = account_, documentId] {
    1530            3 :         if (auto account = w.lock())
    1531            3 :             if (auto* cm = account->convModule())
    1532            3 :                 cm->removeDocumentReplica(documentId);
    1533            3 :     });
    1534            3 :     emitSignal<libjami::ConversationSignal::CollaborativeDocumentRemoved>(accountId_, conversationId, documentId, true);
    1535            3 : }
    1536              : 
    1537              : void
    1538           36 : CollaborativeEditing::onRepositoryUpdated(const std::string& conversationId, const std::string& documentId)
    1539              : {
    1540              :     // A remote rename can land on a document nobody has open here, and both
    1541              :     // early returns below are reachable in that case: drop the cached name first
    1542              :     // or a client would keep showing the old one until the account restarts.
    1543              :     {
    1544           36 :         std::lock_guard<std::mutex> lk(mutex_);
    1545           36 :         nameCache_.erase(key(conversationId, documentId));
    1546           36 :         ++nameEpoch_;
    1547           36 :     }
    1548           36 :     auto session = findSession(conversationId, documentId);
    1549           36 :     auto conversation = documentConversation(documentId);
    1550           36 :     if (!session || !conversation)
    1551            0 :         return; // not being edited here; the repository is up to date on disk
    1552              :     {
    1553           36 :         std::lock_guard<std::mutex> lk(mutex_);
    1554           36 :         if (!session->persistedLoaded)
    1555            0 :             return; // never opened: it will be replayed on open
    1556           36 :     }
    1557              :     // What the replica knows before the replay, so that what it learns from it
    1558              :     // can be told apart from what it already had.
    1559           36 :     const auto before = session->doc->encodeStateVector();
    1560           36 :     session->doc->takeChanged();
    1561              :     // Applying an update the replica already knows is a no-op for a CRDT, so
    1562              :     // replaying the whole set is correct, just more work than strictly needed.
    1563           36 :     replayStoredUpdates(session);
    1564              :     // Nothing at all when the replay taught us nothing -- a rename-only commit,
    1565              :     // or updates the real-time path had already delivered -- or every client
    1566              :     // would light an "unread" badge for a document nobody touched. The question
    1567              :     // is put to yrs rather than answered by measuring the diff: yrs appends the
    1568              :     // whole deletion set to a diff without diffing it, so a diff carrying no new
    1569              :     // content still measures a few bytes on any document where a character was
    1570              :     // ever erased.
    1571              :     //
    1572              :     // What is sent is only what the replay brought, not the whole document: a
    1573              :     // synchronization usually carries a handful of keystrokes, and re-encoding
    1574              :     // a 300 kB document for each of them would push megabytes a minute through
    1575              :     // the client API for nothing. A closed holder's client is not handed the
    1576              :     // content either -- it gets the converged state when it reopens -- but it
    1577              :     // is told that there is some: an update with an empty payload, which is
    1578              :     // what lets it badge a document nobody here is watching.
    1579              :     bool tellClient;
    1580              :     {
    1581           36 :         std::lock_guard<std::mutex> lk(mutex_);
    1582           36 :         tellClient = session->open;
    1583           36 :     }
    1584           36 :     if (session->doc->takeChanged())
    1585            5 :         emitUpdate(conversationId, documentId, tellClient ? session->doc->encodeDiff(before) : YrsDocument::Bytes {});
    1586              :     // Independent of the updates above: an attachment is not part of the CRDT,
    1587              :     // so a synchronization can bring the payload of a reference the real-time
    1588              :     // path delivered long before, with no update at all to show for it.
    1589           36 :     emitNewAttachments(session);
    1590              :     // The name travels with the repository now, so a remote rename lands here.
    1591              :     // It cannot be detected by reading the name around the replay: the caller
    1592              :     // merges before calling us, so both reads would return the name from after
    1593              :     // the merge. What we compare against is the last name we told the clients.
    1594           36 :     std::string name;
    1595              :     {
    1596           36 :         auto infos = conversation->infos();
    1597           72 :         if (auto it = infos.find("title"); it != infos.end())
    1598           36 :             name = it->second;
    1599           36 :     }
    1600           36 :     auto renamed = false;
    1601              :     {
    1602           36 :         std::lock_guard<std::mutex> lk(mutex_);
    1603           36 :         nameCache_[key(conversationId, documentId)] = name;
    1604           36 :         ++nameEpoch_;
    1605              :         // Only a name the clients have already been told can be seen to change.
    1606           36 :         if (session->announcedName && *session->announcedName != name)
    1607            1 :             renamed = true;
    1608           36 :         if (session->announcedName)
    1609           36 :             session->announcedName = name;
    1610           36 :     }
    1611           36 :     if (renamed)
    1612            1 :         emitRename(conversationId, documentId, name);
    1613              :     // A synchronization is also how new editors become reachable: the clone
    1614              :     // this session may have been waiting for just landed, or a joiner's
    1615              :     // membership commits were just merged -- the very merge that entitles the
    1616              :     // device this replica refused a moment ago to its channel.
    1617           36 :     connectRealtimeChannels(session);
    1618           36 : }
    1619              : 
    1620              : void
    1621          174 : CollaborativeEditing::flush()
    1622              : {
    1623          174 :     std::vector<std::shared_ptr<Session>> sessions;
    1624              :     {
    1625          174 :         std::lock_guard<std::mutex> lk(mutex_);
    1626          174 :         sessions.reserve(sessions_.size());
    1627          203 :         for (const auto& [_, session] : sessions_)
    1628           29 :             sessions.emplace_back(session);
    1629          174 :     }
    1630          203 :     for (const auto& session : sessions) {
    1631           29 :         if (session->checkpointTimer) {
    1632           29 :             std::lock_guard<std::mutex> lk(session->timerMutex);
    1633           29 :             session->checkpointTimer->cancel();
    1634           29 :         }
    1635              :         // No compaction here: the edits are already durable, packing is pure
    1636              :         // housekeeping, and doing it inline would stall the account
    1637              :         // unregistration for seconds per open document.
    1638           29 :         checkpointNow(session);
    1639              :     }
    1640          174 : }
    1641              : 
    1642              : void
    1643            6 : CollaborativeEditing::emitUpdate(const std::string& conversationId,
    1644              :                                  const std::string& documentId,
    1645              :                                  const YrsDocument::Bytes& update)
    1646              : {
    1647              :     // One channel for every document type: the payload is an opaque Y-CRDT
    1648              :     // update, and the client merges it into its own replica. That is what makes
    1649              :     // a plain-text editor and a rich-text editor listen to the same signal.
    1650            6 :     emitSignal<libjami::ConversationSignal::CollaborativeDocumentUpdate>(accountId_, conversationId, documentId, update);
    1651            6 : }
    1652              : 
    1653              : void
    1654            3 : CollaborativeEditing::emitRename(const std::string& conversationId,
    1655              :                                  const std::string& documentId,
    1656              :                                  const std::string& name)
    1657              : {
    1658            3 :     emitSignal<libjami::ConversationSignal::CollaborativeDocumentRenamed>(accountId_, conversationId, documentId, name);
    1659            3 : }
    1660              : 
    1661              : void
    1662           36 : CollaborativeEditing::emitNewAttachments(const std::shared_ptr<Session>& session)
    1663              : {
    1664           36 :     auto conversation = documentConversation(session->documentId);
    1665           36 :     if (!conversation)
    1666            0 :         return;
    1667           36 :     auto ids = conversation->documentAttachmentIds();
    1668           36 :     std::vector<std::string> fresh;
    1669              :     {
    1670           36 :         std::lock_guard<std::mutex> lk(mutex_);
    1671           37 :         for (auto& id : ids)
    1672            1 :             if (session->knownAttachments.insert(id).second)
    1673            1 :                 fresh.push_back(std::move(id));
    1674           36 :     }
    1675           37 :     for (const auto& id : fresh)
    1676            2 :         emitSignal<libjami::ConversationSignal::CollaborativeAttachmentAdded>(accountId_,
    1677            1 :                                                                               session->conversationId,
    1678            1 :                                                                               session->documentId,
    1679              :                                                                               id);
    1680           36 : }
    1681              : 
    1682              : std::string
    1683            1 : CollaborativeEditing::addAttachment(const std::string& conversationId,
    1684              :                                     const std::string& documentId,
    1685              :                                     const std::vector<uint8_t>& data)
    1686              : {
    1687            1 :     if (data.empty() || data.size() > MAX_ATTACHMENT_SIZE) {
    1688            0 :         JAMI_WARNING("[Account {}] [Document {}] Attachment refused: {} byte(s), limit is {}",
    1689              :                      accountId_,
    1690              :                      documentId,
    1691              :                      data.size(),
    1692              :                      MAX_ATTACHMENT_SIZE);
    1693            0 :         return {};
    1694              :     }
    1695            1 :     auto account = account_.lock();
    1696            1 :     if (!account)
    1697            0 :         return {};
    1698            1 :     auto* cm = account->convModule();
    1699            1 :     if (!cm)
    1700            0 :         return {};
    1701              :     // The payload is a commit in the document's own swarm, and committing it is
    1702              :     // also what announces it: peers fetch it straight away rather than showing a
    1703              :     // placeholder until the next checkpoint.
    1704            1 :     auto id = cm->addDocumentAttachment(documentId, data);
    1705            1 :     if (id.empty())
    1706            0 :         return {};
    1707            1 :     if (auto session = findSession(conversationId, documentId)) {
    1708              :         // Ours already: the client that stored it holds the bytes, and the next
    1709              :         // synchronization must not announce them back to it.
    1710            1 :         std::lock_guard<std::mutex> lk(mutex_);
    1711            1 :         session->knownAttachments.insert(id);
    1712            2 :     }
    1713            1 :     return id;
    1714            1 : }
    1715              : 
    1716              : std::vector<uint8_t>
    1717            3 : CollaborativeEditing::attachment(const std::string& /*conversationId*/,
    1718              :                                  const std::string& documentId,
    1719              :                                  const std::string& attachmentId)
    1720              : {
    1721              :     // Readable without an editing session: a client browsing the history of a
    1722              :     // document it has not opened still has to resolve what it refers to.
    1723            3 :     auto conversation = documentConversation(documentId);
    1724            6 :     return conversation ? conversation->documentAttachment(attachmentId) : std::vector<uint8_t> {};
    1725            3 : }
    1726              : 
    1727              : } // namespace jami
        

Generated by: LCOV version 2.0-1