1 /*
2  *
3  * SessionSourceDatabaseSupervisor.cpp
4  *
5  * Copyright (C) 2021 by RStudio, PBC
6  *
7  * Unless you have received this program directly from RStudio pursuant
8  * to the terms of a commercial license agreement with RStudio, then
9  * this program is licensed to you under the terms of version 3 of the
10  * GNU Affero General Public License. This program is distributed WITHOUT
11  * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
12  * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
13  * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
14  *
15  */
16 
17 #include "SessionSourceDatabaseSupervisor.hpp"
18 
19 #ifdef _WIN32
20 # include <winsock2.h>
21 # include <windows.h>
22 #endif
23 
24 #include <vector>
25 
26 #include <boost/scope_exit.hpp>
27 #include <boost/algorithm/string/predicate.hpp>
28 
29 #include <shared_core/Error.hpp>
30 #include <shared_core/FilePath.hpp>
31 #include <core/FileSerializer.hpp>
32 #include <core/FileLock.hpp>
33 #include <core/FileUtils.hpp>
34 #include <core/BoostErrors.hpp>
35 
36 #include <r/session/RSession.hpp>
37 
38 #include <core/system/System.hpp>
39 
40 #include <session/SessionOptions.hpp>
41 #include <session/SessionModuleContext.hpp>
42 #include "session/SessionSourceDatabase.hpp"
43 
44 using namespace rstudio::core;
45 
46 namespace rstudio {
47 namespace session {
48 namespace source_database {
49 namespace supervisor {
50 
51 namespace {
52 
53 const char * const kSessionDirPrefix = "s-";
54 
sdbSourceDatabaseRoot()55 FilePath sdbSourceDatabaseRoot()
56 {
57    return module_context::scopedScratchPath().completePath("sdb");
58 }
59 
sourceDatabaseRoot()60 FilePath sourceDatabaseRoot()
61 {
62    return module_context::scopedScratchPath().completePath(kSessionSourceDatabasePrefix);
63 }
64 
mostRecentTitledDir()65 FilePath mostRecentTitledDir()
66 {
67    return module_context::scopedScratchPath().completePath(kSessionSourceDatabasePrefix "/mt");
68 }
69 
mostRecentUntitledDir()70 FilePath mostRecentUntitledDir()
71 {
72    return module_context::scopedScratchPath().completePath(kSessionSourceDatabasePrefix "/mu");
73 }
74 
persistentTitledDir(bool multiSession=true)75 FilePath persistentTitledDir(bool multiSession = true)
76 {
77    if (multiSession && options().multiSession() && options().programMode() == kSessionProgramModeServer)
78    {
79       std::string id = module_context::activeSession().id();
80       return sourceDatabaseRoot().completePath("per/t/" + id);
81    }
82    else
83    {
84       return sourceDatabaseRoot().completePath("per/t");
85    }
86 }
87 
oldPersistentTitledDir()88 FilePath oldPersistentTitledDir()
89 {
90    FilePath oldPath = module_context::oldScopedScratchPath();
91    if (oldPath.exists())
92       return oldPath.completePath("source_database_v2/persistent/titled");
93    else
94       return FilePath();
95 }
96 
persistentUntitledDir(bool multiSession=true)97 FilePath persistentUntitledDir(bool multiSession = true)
98 {
99    if (multiSession && options().multiSession() && options().programMode() == kSessionProgramModeServer)
100    {
101       std::string id = module_context::activeSession().id();
102       return sourceDatabaseRoot().completePath("per/u/" + id);
103    }
104    else
105    {
106       return sourceDatabaseRoot().completePath("per/u");
107    }
108 }
109 
oldPersistentUntitledDir()110 FilePath oldPersistentUntitledDir()
111 {
112    FilePath oldPath = module_context::oldScopedScratchPath();
113    if (oldPath.exists())
114       return oldPath.completePath("source_database_v2/persistent/untitled");
115    else
116       return FilePath();
117 }
118 
sessionLockFilePath(const FilePath & sessionDir)119 FilePath sessionLockFilePath(const FilePath& sessionDir)
120 {
121    return sessionDir.completePath("lock_file");
122 }
123 
sessionSuspendFilePath(const FilePath & sessionDir)124 FilePath sessionSuspendFilePath(const FilePath& sessionDir)
125 {
126    return sessionDir.completePath("suspend_file");
127 }
128 
sessionRestartFilePath(const FilePath & sessionDir)129 FilePath sessionRestartFilePath(const FilePath& sessionDir)
130 {
131    return sessionDir.completePath("restart_file");
132 }
133 
134 // session dir lock (lock is acquired within 'attachToSourceDatabase()')
createSessionDirLock()135 boost::shared_ptr<FileLock> createSessionDirLock()
136 {
137    return FileLock::createDefault();
138 }
139 
sessionDirLock()140 FileLock& sessionDirLock()
141 {
142    static boost::shared_ptr<FileLock> instance = createSessionDirLock();
143    return *instance;
144 }
145 
removeSessionDir(const FilePath & sessionDir)146 Error removeSessionDir(const FilePath& sessionDir)
147 {
148    // first remove children
149    std::vector<FilePath> children;
150    Error error = sessionDir.getChildren(children);
151    if (error)
152       LOG_ERROR(error);
153    for (const FilePath& filePath : children)
154    {
155       error = filePath.remove();
156       if (error)
157          LOG_ERROR(error);
158    }
159 
160    // then remove dir
161    return sessionDir.remove();
162 }
163 
isNotSessionDir(const FilePath & filePath)164 bool isNotSessionDir(const FilePath& filePath)
165 {
166    return !filePath.isDirectory() || !boost::algorithm::starts_with(
167                                                 filePath.getFilename(),
168                                                 kSessionDirPrefix);
169 }
170 
enumerateSessionDirs(std::vector<FilePath> * pSessionDirs)171 Error enumerateSessionDirs(std::vector<FilePath>* pSessionDirs)
172 {
173    // get the directories
174    Error error = sourceDatabaseRoot().getChildren(*pSessionDirs);
175    if (error)
176       return error;
177 
178    // clean out non session dirs
179    pSessionDirs->erase(std::remove_if(pSessionDirs->begin(),
180                                       pSessionDirs->end(),
181                                       isNotSessionDir),
182                        pSessionDirs->end());
183 
184    // return success
185    return Success();
186 }
187 
attemptToMoveSourceDbFiles(const FilePath & fromPath,const FilePath & toPath)188 void attemptToMoveSourceDbFiles(const FilePath& fromPath,
189                                 const FilePath& toPath)
190 {
191    // enumerate the from path
192    std::vector<FilePath> children;
193    Error error = fromPath.getChildren(children);
194    if (error)
195       LOG_ERROR(error);
196 
197    // move the files
198    for (const FilePath& filePath : children)
199    {
200       // skip directories (directories can exist because multi-session
201       // mode writes top level directories into the /sdb/t and /sdb/u
202       // stores -- these directories correspond to the persistent docs
203       // of particular long-running sessions)
204       if (filePath.isDirectory())
205          continue;
206 
207       // if the target path already exists then skip it and log
208       // (we used to generate a new uniqueFilePath however this
209       // caused the filename and id (stored in the source doc)
210       // to get out of sync, making documents unclosable. The
211       // chance of file with the same name already existing is
212       // close to zero (collision probability of uniqueFilePath)
213       // so it's no big deal to punt here.
214       FilePath targetPath = toPath.completePath(filePath.getFilename());
215       if (targetPath.exists())
216       {
217          LOG_WARNING_MESSAGE("Skipping source db move from: " +
218                                 filePath.getAbsolutePath() + " to " +
219                              targetPath.getAbsolutePath());
220 
221          Error error = filePath.remove();
222          if (error)
223             LOG_ERROR(error);
224 
225          continue;
226       }
227 
228       Error error = filePath.move(targetPath);
229       if (error)
230          LOG_ERROR(error);
231    }
232 }
233 
234 // NOTE: the supervisor needs to return a session dir in order for the process
235 // to start. therefore, in the createSessionDir family of functions below
236 // once we successfully create and lock the session dir other errors (such
237 // as trying to move files into the session dir) are simply logged
238 
createSessionDir()239 Error createSessionDir()
240 {
241    Error error = sessionDirPath().ensureDirectory();
242    if (error)
243       return error;
244 
245    // attempt to acquire the lock. if we can't then we still continue
246    // so we can support filesystems that don't have file locks.
247    error = sessionDirLock().acquire(sessionLockFilePath(sessionDirPath()));
248    if (error)
249       LOG_ERROR(error);
250 
251    return Success();
252 }
253 
createSessionDirFromPersistent()254 Error createSessionDirFromPersistent()
255 {
256    // note whether we are in multi-session mode
257    bool multiSession = options().multiSession() && options().programMode() == kSessionProgramModeServer;
258 
259    // create new session dir
260    Error error = createSessionDir();
261    if (error)
262       return error;
263 
264    // move persistent titled files. if we don't have any this is a
265    // brand new instantiation of this project within this session
266    // so we try to grab the MRU list
267    if (persistentTitledDir().exists())
268    {
269       attemptToMoveSourceDbFiles(persistentTitledDir(), sessionDirPath());
270    }
271    // check for the most recent titled directory
272    else if (multiSession && mostRecentTitledDir().exists())
273    {
274       attemptToMoveSourceDbFiles(mostRecentTitledDir(), sessionDirPath());
275    }
276    // last resort: if we are in multi-session mode see if there is a set of
277    // mono-session source documents that we can migrate
278    else if (multiSession && persistentTitledDir(false).exists())
279    {
280       attemptToMoveSourceDbFiles(persistentTitledDir(false), sessionDirPath());
281    }
282 
283    // get legacy titled docs if they exist
284    if (oldPersistentTitledDir().exists())
285       attemptToMoveSourceDbFiles(oldPersistentTitledDir(), sessionDirPath());
286 
287    // move persistent untitled files
288    if (persistentUntitledDir().exists())
289    {
290       attemptToMoveSourceDbFiles(persistentUntitledDir(), sessionDirPath());
291    }
292    // check for the most recent untitled directory
293    else if (multiSession && mostRecentUntitledDir().exists())
294    {
295       attemptToMoveSourceDbFiles(mostRecentUntitledDir(), sessionDirPath());
296    }
297    // last resort: if we are in multi-session mode see if there is a set of
298    // mono-session source documents that we can migrate
299    else if (multiSession && persistentUntitledDir(false).exists())
300    {
301        attemptToMoveSourceDbFiles(persistentUntitledDir(false), sessionDirPath());
302    }
303 
304    // get legacy untitled docs if they exist
305    if (oldPersistentUntitledDir().exists())
306       attemptToMoveSourceDbFiles(oldPersistentUntitledDir(), sessionDirPath());
307 
308    // return success
309    return Success();
310 }
311 
reclaimOrphanedSession()312 bool reclaimOrphanedSession()
313 {
314    // check for existing sessions
315    std::vector<FilePath> sessionDirs;
316    Error error = enumerateSessionDirs(&sessionDirs);
317    if (error)
318    {
319       LOG_ERROR(error);
320       return false;
321    }
322 
323    for (const FilePath& sessionDir : sessionDirs)
324    {
325       // if the suspend file exists, this session is only sleeping, not dead
326       if (sessionSuspendFilePath(sessionDir).exists())
327          continue;
328 
329       FilePath restartFile = sessionRestartFilePath(sessionDir);
330       if (restartFile.exists())
331       {
332          if (std::time(nullptr) - restartFile.getLastWriteTime() >
333               (1000 * 60 * 5))
334          {
335             // the file exists, but it's more than five minutes old, so
336             // something went wrong
337             Error error = restartFile.remove();
338             if (error)
339                LOG_ERROR(error);
340          }
341          else
342          {
343             // the restart file exists and is new, so it represents a
344             // session currently undergoing a suspend for restart -- leave
345             // it alone
346             continue;
347          }
348       }
349 
350       FilePath lockFilePath = sessionLockFilePath(sessionDir);
351       if (!sessionDirLock().isLocked(lockFilePath))
352       {
353          // adopt by giving the session dir our own name
354          Error error = sessionDir.move(sessionDirPath());
355          if (error)
356             LOG_ERROR(error);
357          else
358          {
359             error = sessionDirLock().acquire(
360                   sessionLockFilePath(sessionDirPath()));
361             if (!error)
362             {
363                return true;
364             }
365             else
366             {
367                LOG_ERROR(error);
368             }
369          }
370       }
371    }
372 
373    return false;
374 }
375 
removeAndRecreate(const FilePath & dir)376 Error removeAndRecreate(const FilePath& dir)
377 {
378    // blow it away if it exists then recreate it
379    Error error = dir.removeIfExists();
380    if (error)
381       LOG_ERROR(error);
382    return dir.ensureDirectory();
383 }
384 
385 } // anonymous namespace
386 
387 
388 // NOTE: we attempt to use file locks to coordinate between disperate
389 // processes all attempting to open a session in the same context (project
390 // or global). Locks are used to implement recovery of crashed sessions
391 // as follows: if there is an existing source-db directory on disk that
392 // is NOT locked then it's presumed to be an orphan (resulting from a crash)
393 // and we should initialize with this directory to "recover" it
394 //
395 // Unfortunately, some file systems (mostly remote network volumes) don't
396 // support file-locking. In these cases we need to gracefully fall back
397 // to some sane behavior. To implement this we use the following scheme:
398 //
399 //  (1) Always attempt to call FileLock::acquire to create an advisory lock
400 //      but if it fails we still allow the process to start up.
401 //
402 //  (2) When checking for "orphan" source-db directories we try to acquire
403 //      a lock on them -- for volumes that don't support locks this will
404 //      always be an error so we'll never be able to recover an orphan dir
405 //
406 // In some multi-machine cases it's actually possible for two proccesses
407 // to both get a lock on the same file. For this reason if we are running
408 // multi-machine (i.e. load balancing enabled) we don't attempt orphan
409 // recovery because it could result in one session stealing the other's
410 // source database out from under it.
411 //
412 
attachToSourceDatabase()413 Error attachToSourceDatabase()
414 {
415    // this session may already have a source database; if it does, re-acquire a
416    // lock and then use it. don't log warnings as this should only fail when
417    // e.g. the filesystem does not support the active locking scheme
418    FilePath existingSdb = sessionDirPath();
419    if (existingSdb.exists())
420    {
421       Error error = sessionDirLock().acquire(sessionLockFilePath(existingSdb));
422       if (error)
423       {
424          LOG_ERROR(error);
425       }
426       else
427       {
428          return Success();
429       }
430    }
431 
432    // migrate from 'sdb' to current folder layout if needed
433    bool needsSdbMigration =
434          !sourceDatabaseRoot().exists() &&
435          sdbSourceDatabaseRoot().exists();
436 
437    if (needsSdbMigration)
438    {
439       Error error =
440             sdbSourceDatabaseRoot().copyDirectoryRecursive(sourceDatabaseRoot());
441 
442       if (error)
443          LOG_ERROR(error);
444    }
445 
446    // ensure the root path exists
447    Error error = sourceDatabaseRoot().ensureDirectory();
448    if (error)
449       return error;
450 
451    // if there is an orphan (crash) then reclaim it.
452    if (reclaimOrphanedSession())
453    {
454       return Success();
455    }
456 
457    // attempt to create from persistent
458    else
459    {
460       return createSessionDirFromPersistent();
461    }
462 }
463 
464 // preserve documents for re-opening in a future session
saveMostRecentDocuments()465 Error saveMostRecentDocuments()
466 {
467    // only do this for multi-session contexts
468    if (options().multiSession() && options().programMode() == kSessionProgramModeServer)
469    {
470       // most recent docs is last one wins so we remove and recreate
471       FilePath mostRecentDir = mostRecentTitledDir();
472       Error error = removeAndRecreate(mostRecentDir);
473       if (error)
474          return error;
475 
476       // untitled are aggregated (so we never lose unsaved docs)
477       // so we just ensure the directory exists)
478       FilePath mostRecentDirUntitled = mostRecentUntitledDir();
479       error = mostRecentDirUntitled.ensureDirectory();
480       if (error)
481          return error;
482 
483       // list all current source docs
484       std::vector<boost::shared_ptr<SourceDocument> > sourceDocs;
485       error = source_database::list(&sourceDocs);
486       if (error)
487          return error;
488 
489       // write the docs into the mru directories
490       for (boost::shared_ptr<SourceDocument> pDoc : sourceDocs)
491       {
492          FilePath targetDir = pDoc->isUntitled() ? mostRecentDirUntitled :
493                                                    mostRecentDir;
494 
495          Error error = pDoc->writeToFile(targetDir.completeChildPath(pDoc->id()));
496          if (error)
497             LOG_ERROR(error);
498       }
499    }
500 
501    return Success();
502 }
503 
detachFromSourceDatabase()504 Error detachFromSourceDatabase()
505 {
506    // list all current source docs
507    std::vector<boost::shared_ptr<SourceDocument> > sourceDocs;
508    Error error = source_database::list(&sourceDocs);
509    if (error)
510       return error;
511 
512    // get references to persistent subdirs
513    FilePath titledDir = persistentTitledDir();
514    FilePath untitledDir = persistentUntitledDir();
515 
516    // first blow away the existing persistent titled dir
517    error = titledDir.removeIfExists();
518    if (error)
519       LOG_ERROR(error);
520 
521    // ensure both directories exist -- if they don't it is a fatal error
522    error = titledDir.ensureDirectory();
523    if (error)
524       return error;
525    error = untitledDir.ensureDirectory();
526    if (error)
527       return error;
528 
529    // now write the source database entries to the appropriate places
530    for (boost::shared_ptr<SourceDocument> pDoc : sourceDocs)
531    {
532       if (pDoc->isUntitled())
533       {
534          // compute the target path (manage uniqueness since this
535          // directory is appended to from multiple processes who
536          // could have created docs with the same id)
537          FilePath targetPath = untitledDir.completePath(pDoc->id());
538          if (targetPath.exists())
539             targetPath = file_utils::uniqueFilePath(untitledDir);
540 
541          error = pDoc->writeToFile(targetPath);
542          if (error)
543             LOG_ERROR(error);
544       }
545       else
546       {
547          error = pDoc->writeToFile(titledDir.completePath(pDoc->id()));
548          if (error)
549             LOG_ERROR(error);
550       }
551    }
552 
553    // record session dir (parent of lock file)
554    FilePath sessionDir = sessionDirLock().lockFilePath().getParent();
555 
556    // give up our lock
557    error = sessionDirLock().release();
558    if (error)
559       LOG_ERROR(error);
560 
561    // remove the session directory
562    return removeSessionDir(sessionDir);
563 }
564 
sessionDirPath()565 FilePath sessionDirPath()
566 {
567    return sourceDatabaseRoot().completePath(
568       kSessionDirPrefix +
569       module_context::activeSession().id());
570 }
571 
suspendSourceDatabase(int status)572 void suspendSourceDatabase(int status)
573 {
574    // write a sentinel so we can differentiate between a sdb that's orphaned
575    // from a crash, and an sdb that represents a suspended session
576    Error error = (status == EX_SUSPEND_RESTART_LAUNCHER_SESSION ||
577                   status == EX_CONTINUE) ?
578       sessionRestartFilePath(sessionDirPath()).ensureFile() :
579       sessionSuspendFilePath(sessionDirPath()).ensureFile();
580    if (error)
581       LOG_ERROR(error);
582 }
583 
resumeSourceDatabase()584 void resumeSourceDatabase()
585 {
586    Error error = sessionSuspendFilePath(sessionDirPath()).removeIfExists();
587    if (error)
588       LOG_ERROR(error);
589    error = sessionRestartFilePath(sessionDirPath()).removeIfExists();
590    if (error)
591       LOG_ERROR(error);
592 }
593 
594 } // namespace supervisor
595 } // namespace source_database
596 } // namespace session
597 } // namespace rstudio
598 
599 
600 
601