Просмотр исходного кода

Merge pull request #8714 from overleaf/em-promisify-compile-manager

Promisify CompileManager

GitOrigin-RevId: 644ed061ae139d6196b24f8ead38579de6b844a3
Eric Mc Sween 4 лет назад
Родитель
Сommit
77aa2baa9d

+ 2 - 0
package-lock.json

@@ -31888,6 +31888,7 @@
         "chai": "^4.3.6",
         "chai": "^4.3.6",
         "chai-as-promised": "^7.1.1",
         "chai-as-promised": "^7.1.1",
         "mocha": "^8.4.0",
         "mocha": "^8.4.0",
+        "mock-fs": "^5.1.2",
         "sandboxed-module": "^2.0.4",
         "sandboxed-module": "^2.0.4",
         "sinon": "~9.0.1",
         "sinon": "~9.0.1",
         "sinon-chai": "^3.7.0",
         "sinon-chai": "^3.7.0",
@@ -39708,6 +39709,7 @@
         "lockfile": "^1.0.4",
         "lockfile": "^1.0.4",
         "lodash": "^4.17.21",
         "lodash": "^4.17.21",
         "mocha": "^8.4.0",
         "mocha": "^8.4.0",
+        "mock-fs": "^5.1.2",
         "p-limit": "^3.1.0",
         "p-limit": "^3.1.0",
         "pdfjs-dist": "~2.7.570",
         "pdfjs-dist": "~2.7.570",
         "request": "^2.88.2",
         "request": "^2.88.2",

+ 82 - 79
services/clsi/app/js/CompileController.js

@@ -29,93 +29,96 @@ function compile(req, res, next) {
         if (error) {
         if (error) {
           return next(error)
           return next(error)
         }
         }
-        CompileManager.doCompileWithLock(
-          request,
-          function (error, outputFiles, stats, timings) {
-            let code, status
-            if (outputFiles == null) {
-              outputFiles = []
-            }
-            if (error instanceof Errors.AlreadyCompilingError) {
-              code = 423 // Http 423 Locked
-              status = 'compile-in-progress'
-            } else if (error instanceof Errors.FilesOutOfSyncError) {
-              code = 409 // Http 409 Conflict
-              status = 'retry'
-            } else if (error?.code === 'EPIPE') {
-              // docker returns EPIPE when shutting down
-              code = 503 // send 503 Unavailable response
-              status = 'unavailable'
-            } else if (error?.terminated) {
-              status = 'terminated'
-            } else if (error?.validate) {
-              status = `validation-${error.validate}`
-            } else if (error?.timedout) {
-              status = 'timedout'
-              logger.debug(
-                { err: error, project_id: request.project_id },
-                'timeout running compile'
+        CompileManager.doCompileWithLock(request, (error, result) => {
+          let { outputFiles, stats, timings } = result || {}
+          let code, status
+          if (outputFiles == null) {
+            outputFiles = []
+          }
+          if (error instanceof Errors.AlreadyCompilingError) {
+            code = 423 // Http 423 Locked
+            status = 'compile-in-progress'
+          } else if (error instanceof Errors.FilesOutOfSyncError) {
+            code = 409 // Http 409 Conflict
+            status = 'retry'
+            logger.warn(
+              {
+                projectId: request.project_id,
+                userId: request.user_id,
+              },
+              'files out of sync, please retry'
+            )
+          } else if (error?.code === 'EPIPE') {
+            // docker returns EPIPE when shutting down
+            code = 503 // send 503 Unavailable response
+            status = 'unavailable'
+          } else if (error?.terminated) {
+            status = 'terminated'
+          } else if (error?.validate) {
+            status = `validation-${error.validate}`
+          } else if (error?.timedout) {
+            status = 'timedout'
+            logger.debug(
+              { err: error, projectId: request.project_id },
+              'timeout running compile'
+            )
+          } else if (error) {
+            status = 'error'
+            code = 500
+            logger.error(
+              { err: error, projectId: request.project_id },
+              'error running compile'
+            )
+          } else {
+            if (
+              outputFiles.some(
+                file => file.path === 'output.pdf' && file.size > 0
               )
               )
-            } else if (error) {
-              status = 'error'
-              code = 500
+            ) {
+              status = 'success'
+              lastSuccessfulCompileTimestamp = Date.now()
+            } else if (request.stopOnFirstError) {
+              status = 'stopped-on-first-error'
+            } else {
+              status = 'failure'
               logger.warn(
               logger.warn(
-                { err: error, project_id: request.project_id },
-                'error running compile'
+                { projectId: request.project_id, outputFiles },
+                'project failed to compile successfully, no output.pdf generated'
               )
               )
-            } else {
-              if (
-                outputFiles.some(
-                  file => file.path === 'output.pdf' && file.size > 0
-                )
-              ) {
-                status = 'success'
-                lastSuccessfulCompileTimestamp = Date.now()
-              } else if (request.stopOnFirstError) {
-                status = 'stopped-on-first-error'
-              } else {
-                status = 'failure'
-                logger.warn(
-                  { project_id: request.project_id, outputFiles },
-                  'project failed to compile successfully, no output.pdf generated'
-                )
-              }
-
-              // log an error if any core files are found
-              if (outputFiles.some(file => file.path === 'core')) {
-                logger.error(
-                  { project_id: request.project_id, req, outputFiles },
-                  'core file found in output'
-                )
-              }
             }
             }
 
 
-            if (error) {
-              outputFiles = error.outputFiles || []
+            // log an error if any core files are found
+            if (outputFiles.some(file => file.path === 'core')) {
+              logger.error(
+                { projectId: request.project_id, req, outputFiles },
+                'core file found in output'
+              )
             }
             }
+          }
 
 
-            timer.done()
-            res.status(code || 200).send({
-              compile: {
-                status,
-                error: error?.message || error,
-                stats,
-                timings,
-                outputUrlPrefix: Settings.apis.clsi.outputUrlPrefix,
-                outputFiles: outputFiles.map(file => ({
-                  url:
-                    `${Settings.apis.clsi.url}/project/${request.project_id}` +
-                    (request.user_id != null
-                      ? `/user/${request.user_id}`
-                      : '') +
-                    (file.build != null ? `/build/${file.build}` : '') +
-                    `/output/${file.path}`,
-                  ...file,
-                })),
-              },
-            })
+          if (error) {
+            outputFiles = error.outputFiles || []
           }
           }
-        )
+
+          timer.done()
+          res.status(code || 200).send({
+            compile: {
+              status,
+              error: error?.message || error,
+              stats,
+              timings,
+              outputUrlPrefix: Settings.apis.clsi.outputUrlPrefix,
+              outputFiles: outputFiles.map(file => ({
+                url:
+                  `${Settings.apis.clsi.url}/project/${request.project_id}` +
+                  (request.user_id != null ? `/user/${request.user_id}` : '') +
+                  (file.build != null ? `/build/${file.build}` : '') +
+                  `/output/${file.path}`,
+                ...file,
+              })),
+            },
+          })
+        })
       }
       }
     )
     )
   })
   })

+ 403 - 481
services/clsi/app/js/CompileManager.js

@@ -1,24 +1,29 @@
+const childProcess = require('child_process')
+const fsPromises = require('fs/promises')
+const fse = require('fs-extra')
+const os = require('os')
+const Path = require('path')
+const { callbackify, promisify } = require('util')
+
+const Settings = require('@overleaf/settings')
+const logger = require('@overleaf/logger')
+const OError = require('@overleaf/o-error')
+
 const ResourceWriter = require('./ResourceWriter')
 const ResourceWriter = require('./ResourceWriter')
 const LatexRunner = require('./LatexRunner')
 const LatexRunner = require('./LatexRunner')
 const OutputFileFinder = require('./OutputFileFinder')
 const OutputFileFinder = require('./OutputFileFinder')
 const OutputCacheManager = require('./OutputCacheManager')
 const OutputCacheManager = require('./OutputCacheManager')
-const Settings = require('@overleaf/settings')
-const Path = require('path')
-const logger = require('@overleaf/logger')
 const Metrics = require('./Metrics')
 const Metrics = require('./Metrics')
-const childProcess = require('child_process')
 const DraftModeManager = require('./DraftModeManager')
 const DraftModeManager = require('./DraftModeManager')
 const TikzManager = require('./TikzManager')
 const TikzManager = require('./TikzManager')
 const LockManager = require('./LockManager')
 const LockManager = require('./LockManager')
-const fs = require('fs')
-const fse = require('fs-extra')
-const os = require('os')
-const async = require('async')
 const Errors = require('./Errors')
 const Errors = require('./Errors')
 const CommandRunner = require('./CommandRunner')
 const CommandRunner = require('./CommandRunner')
 const { emitPdfStats } = require('./ContentCacheMetrics')
 const { emitPdfStats } = require('./ContentCacheMetrics')
 const SynctexOutputParser = require('./SynctexOutputParser')
 const SynctexOutputParser = require('./SynctexOutputParser')
 
 
+const execFile = promisify(childProcess.execFile)
+
 const COMPILE_TIME_BUCKETS = [
 const COMPILE_TIME_BUCKETS = [
   // NOTE: These buckets are locked in per metric name.
   // NOTE: These buckets are locked in per metric name.
   //       If you want to change them, you will need to rename metrics.
   //       If you want to change them, you will need to rename metrics.
@@ -41,24 +46,21 @@ function getOutputDir(projectId, userId) {
   return Path.join(Settings.path.outputDir, getCompileName(projectId, userId))
   return Path.join(Settings.path.outputDir, getCompileName(projectId, userId))
 }
 }
 
 
-function doCompileWithLock(request, callback) {
+async function doCompileWithLock(request) {
   const compileDir = getCompileDir(request.project_id, request.user_id)
   const compileDir = getCompileDir(request.project_id, request.user_id)
-  const lockFile = Path.join(compileDir, '.project-lock')
   // use a .project-lock file in the compile directory to prevent
   // use a .project-lock file in the compile directory to prevent
   // simultaneous compiles
   // simultaneous compiles
-  fse.ensureDir(compileDir, error => {
-    if (error) {
-      return callback(error)
-    }
-    LockManager.runWithLock(
-      lockFile,
-      releaseLock => doCompile(request, releaseLock),
-      callback
-    )
-  })
+  const lockFile = Path.join(compileDir, '.project-lock')
+  await fse.ensureDir(compileDir)
+  const lock = await LockManager.acquire(lockFile)
+  try {
+    return await doCompile(request)
+  } finally {
+    await lock.release()
+  }
 }
 }
 
 
-function doCompile(request, callback) {
+async function doCompile(request) {
   const compileDir = getCompileDir(request.project_id, request.user_id)
   const compileDir = getCompileDir(request.project_id, request.user_id)
   const outputDir = getOutputDir(request.project_id, request.user_id)
   const outputDir = getOutputDir(request.project_id, request.user_id)
 
 
@@ -68,395 +70,328 @@ function doCompile(request, callback) {
     request.metricsOpts,
     request.metricsOpts,
     COMPILE_TIME_BUCKETS
     COMPILE_TIME_BUCKETS
   )
   )
-  const timer = new Metrics.Timer('write-to-disk', 1, request.metricsOpts)
+  const writeToDiskTimer = new Metrics.Timer(
+    'write-to-disk',
+    1,
+    request.metricsOpts
+  )
   logger.debug(
   logger.debug(
     { projectId: request.project_id, userId: request.user_id },
     { projectId: request.project_id, userId: request.user_id },
     'syncing resources to disk'
     'syncing resources to disk'
   )
   )
-  ResourceWriter.syncResourcesToDisk(
-    request,
+
+  let resourceList
+  try {
+    // NOTE: resourceList is insecure, it should only be used to exclude files from the output list
+    resourceList = await ResourceWriter.promises.syncResourcesToDisk(
+      request,
+      compileDir
+    )
+  } catch (error) {
+    if (error instanceof Errors.FilesOutOfSyncError) {
+      OError.tag(error, 'files out of sync, please retry', {
+        projectId: request.project_id,
+        userId: request.user_id,
+      })
+    } else {
+      OError.tag(error, 'error writing resources to disk', {
+        projectId: request.project_id,
+        userId: request.user_id,
+      })
+    }
+    throw error
+  }
+  logger.debug(
+    {
+      projectId: request.project_id,
+      userId: request.user_id,
+      time_taken: Date.now() - writeToDiskTimer.start,
+    },
+    'written files to disk'
+  )
+  const syncStage = writeToDiskTimer.done()
+
+  // set up environment variables for chktex
+  const env = {}
+  if (Settings.texliveOpenoutAny && Settings.texliveOpenoutAny !== '') {
+    // override default texlive openout_any environment variable
+    env.openout_any = Settings.texliveOpenoutAny
+  }
+  if (Settings.texliveMaxPrintLine && Settings.texliveMaxPrintLine !== '') {
+    // override default texlive max_print_line environment variable
+    env.max_print_line = Settings.texliveMaxPrintLine
+  }
+  // only run chktex on LaTeX files (not knitr .Rtex files or any others)
+  const isLaTeXFile = request.rootResourcePath?.match(/\.tex$/i)
+  if (request.check != null && isLaTeXFile) {
+    env.CHKTEX_OPTIONS = '-nall -e9 -e10 -w15 -w16'
+    env.CHKTEX_ULIMIT_OPTIONS = '-t 5 -v 64000'
+    if (request.check === 'error') {
+      env.CHKTEX_EXIT_ON_ERROR = 1
+    }
+    if (request.check === 'validate') {
+      env.CHKTEX_VALIDATE = 1
+    }
+  }
+
+  // apply a series of file modifications/creations for draft mode and tikz
+  if (request.draft) {
+    await DraftModeManager.promises.injectDraftMode(
+      Path.join(compileDir, request.rootResourcePath)
+    )
+  }
+
+  const needsMainFile = await TikzManager.promises.checkMainFile(
     compileDir,
     compileDir,
-    (error, resourceList) => {
-      // NOTE: resourceList is insecure, it should only be used to exclude files from the output list
-      if (error && error instanceof Errors.FilesOutOfSyncError) {
-        logger.warn(
-          { projectId: request.project_id, userId: request.user_id },
-          'files out of sync, please retry'
-        )
-        return callback(error)
-      } else if (error) {
-        logger.err(
-          {
-            err: error,
-            projectId: request.project_id,
-            userId: request.user_id,
-          },
-          'error writing resources to disk'
-        )
-        return callback(error)
-      }
-      logger.debug(
-        {
-          projectId: request.project_id,
-          userId: request.user_id,
-          time_taken: Date.now() - timer.start,
-        },
-        'written files to disk'
-      )
-      const syncStage = timer.done()
-
-      function injectDraftModeIfRequired(callback) {
-        if (request.draft) {
-          DraftModeManager.injectDraftMode(
-            Path.join(compileDir, request.rootResourcePath),
-            callback
-          )
-        } else {
-          callback()
-        }
-      }
+    request.rootResourcePath,
+    resourceList
+  )
+  if (needsMainFile) {
+    await TikzManager.promises.injectOutputFile(
+      compileDir,
+      request.rootResourcePath
+    )
+  }
 
 
-      const createTikzFileIfRequired = callback =>
-        TikzManager.checkMainFile(
-          compileDir,
-          request.rootResourcePath,
-          resourceList,
-          (error, needsMainFile) => {
-            if (error) {
-              return callback(error)
-            }
-            if (needsMainFile) {
-              TikzManager.injectOutputFile(
-                compileDir,
-                request.rootResourcePath,
-                callback
-              )
-            } else {
-              callback()
-            }
-          }
-        )
-      // set up environment variables for chktex
-      const env = {}
-      if (Settings.texliveOpenoutAny && Settings.texliveOpenoutAny !== '') {
-        // override default texlive openout_any environment variable
-        env.openout_any = Settings.texliveOpenoutAny
-      }
-      if (Settings.texliveMaxPrintLine && Settings.texliveMaxPrintLine !== '') {
-        // override default texlive max_print_line environment variable
-        env.max_print_line = Settings.texliveMaxPrintLine
-      }
-      // only run chktex on LaTeX files (not knitr .Rtex files or any others)
-      const isLaTeXFile =
-        request.rootResourcePath != null
-          ? request.rootResourcePath.match(/\.tex$/i)
-          : undefined
-      if (request.check != null && isLaTeXFile) {
-        env.CHKTEX_OPTIONS = '-nall -e9 -e10 -w15 -w16'
-        env.CHKTEX_ULIMIT_OPTIONS = '-t 5 -v 64000'
-        if (request.check === 'error') {
-          env.CHKTEX_EXIT_ON_ERROR = 1
-        }
-        if (request.check === 'validate') {
-          env.CHKTEX_VALIDATE = 1
-        }
+  const compileTimer = new Metrics.Timer('run-compile', 1, request.metricsOpts)
+  // find the image tag to log it as a metric, e.g. 2015.1 (convert . to - for graphite)
+  let tag = 'default'
+  if (request.imageName != null) {
+    const match = request.imageName.match(/:(.*)/)
+    if (match != null) {
+      tag = match[1].replace(/\./g, '-')
+    }
+  }
+  // exclude smoke test
+  if (!request.project_id.match(/^[0-9a-f]{24}$/)) {
+    tag = 'other'
+  }
+  Metrics.inc('compiles', 1, request.metricsOpts)
+  Metrics.inc(`compiles-with-image.${tag}`, 1, request.metricsOpts)
+  const compileName = getCompileName(request.project_id, request.user_id)
+
+  let compileResult
+  try {
+    compileResult = await LatexRunner.promises.runLatex(compileName, {
+      directory: compileDir,
+      mainFile: request.rootResourcePath,
+      compiler: request.compiler,
+      timeout: request.timeout,
+      image: request.imageName,
+      flags: request.flags,
+      environment: env,
+      compileGroup: request.compileGroup,
+      stopOnFirstError: request.stopOnFirstError,
+    })
+
+    // We use errors to return the validation state. It would be nice to use a
+    // more appropriate mechanism.
+    if (request.check === 'validate') {
+      const validationError = new Error('validation')
+      validationError.validate = 'pass'
+      throw validationError
+    }
+  } catch (originalError) {
+    let error = originalError
+    // request was for validation only
+    if (request.check === 'validate' && !error.validate) {
+      error = new Error('validation')
+      error.validate = originalError.code ? 'fail' : 'pass'
+    }
+
+    // request was for compile, and failed on validation
+    if (request.check === 'error' && originalError.message === 'exited') {
+      error = new Error('compilation')
+      error.validate = 'fail'
+    }
+
+    // compile was killed by user, was a validation, or a compile which failed validation
+    if (error.terminated || error.validate || error.timedout) {
+      // record timeout errors as a separate counter, success is recorded later
+      if (error.timedout) {
+        Metrics.inc('compiles-timeout', 1, request.metricsOpts)
       }
       }
 
 
-      // apply a series of file modifications/creations for draft mode and tikz
-      async.series(
-        [injectDraftModeIfRequired, createTikzFileIfRequired],
-        error => {
-          if (error) {
-            return callback(error)
-          }
-          const timer = new Metrics.Timer('run-compile', 1, request.metricsOpts)
-          // find the image tag to log it as a metric, e.g. 2015.1 (convert . to - for graphite)
-          let tag = 'default'
-          if (request.imageName != null) {
-            const match = request.imageName.match(/:(.*)/)
-            if (match != null) {
-              tag = match[1].replace(/\./g, '-')
-            }
-          }
-          if (!request.project_id.match(/^[0-9a-f]{24}$/)) {
-            tag = 'other'
-          } // exclude smoke test
-          Metrics.inc('compiles', 1, request.metricsOpts)
-          Metrics.inc(`compiles-with-image.${tag}`, 1, request.metricsOpts)
-          const compileName = getCompileName(
-            request.project_id,
-            request.user_id
-          )
-          LatexRunner.runLatex(
-            compileName,
-            {
-              directory: compileDir,
-              mainFile: request.rootResourcePath,
-              compiler: request.compiler,
-              timeout: request.timeout,
-              image: request.imageName,
-              flags: request.flags,
-              environment: env,
-              compileGroup: request.compileGroup,
-              stopOnFirstError: request.stopOnFirstError,
-            },
-            (error, output, stats, timings) => {
-              // request was for validation only
-              if (request.check === 'validate') {
-                const result = error && error.code ? 'fail' : 'pass'
-                error = new Error('validation')
-                error.validate = result
-              }
-              // request was for compile, and failed on validation
-              if (
-                request.check === 'error' &&
-                error &&
-                error.message === 'exited'
-              ) {
-                error = new Error('compilation')
-                error.validate = 'fail'
-              }
-              // record timeout errors as a separate counter, success is recorded later
-              if (error && error.timedout) {
-                Metrics.inc('compiles-timeout', 1, request.metricsOpts)
-              }
-              // compile was killed by user, was a validation, or a compile which failed validation
-              if (
-                error &&
-                (error.terminated || error.validate || error.timedout)
-              ) {
-                return OutputFileFinder.findOutputFiles(
-                  resourceList,
-                  compileDir,
-                  (err, outputFiles) => {
-                    if (err) {
-                      return callback(err)
-                    }
-                    error.outputFiles = outputFiles // return output files so user can check logs
-                    callback(error)
-                  }
-                )
-              }
-              // compile completed normally
-              if (error) {
-                return callback(error)
-              }
-              Metrics.inc('compiles-succeeded', 1, request.metricsOpts)
-              stats = stats || {}
-              for (const metricKey in stats) {
-                const metricValue = stats[metricKey]
-                Metrics.count(metricKey, metricValue, 1, request.metricsOpts)
-              }
-              timings = timings || {}
-              for (const metricKey in timings) {
-                const metricValue = timings[metricKey]
-                Metrics.timing(metricKey, metricValue, 1, request.metricsOpts)
-              }
-              const loadavg =
-                typeof os.loadavg === 'function' ? os.loadavg() : undefined
-              if (loadavg != null) {
-                Metrics.gauge('load-avg', loadavg[0])
-              }
-              const ts = timer.done()
-              logger.debug(
-                {
-                  projectId: request.project_id,
-                  userId: request.user_id,
-                  time_taken: ts,
-                  stats,
-                  timings,
-                  loadavg,
-                },
-                'done compile'
-              )
-              if (stats['latex-runs'] > 0) {
-                Metrics.histogram(
-                  'avg-compile-per-pass-v2',
-                  ts / stats['latex-runs'],
-                  COMPILE_TIME_BUCKETS,
-                  request.metricsOpts
-                )
-                Metrics.timing(
-                  'avg-compile-per-pass-v2',
-                  ts / stats['latex-runs'],
-                  1,
-                  request.metricsOpts
-                )
-              }
-              if (stats['latex-runs'] > 0 && timings['cpu-time'] > 0) {
-                Metrics.timing(
-                  'run-compile-cpu-time-per-pass',
-                  timings['cpu-time'] / stats['latex-runs'],
-                  1,
-                  request.metricsOpts
-                )
-              }
-              // Emit compile time.
-              timings.compile = ts
-
-              const outputStageTimer = new Metrics.Timer(
-                'process-output-files',
-                1,
-                request.metricsOpts
-              )
-
-              OutputFileFinder.findOutputFiles(
-                resourceList,
-                compileDir,
-                (error, outputFiles) => {
-                  if (error) {
-                    return callback(error)
-                  }
-                  OutputCacheManager.saveOutputFiles(
-                    { request, stats, timings },
-                    outputFiles,
-                    compileDir,
-                    outputDir,
-                    (err, newOutputFiles) => {
-                      if (err) {
-                        const { project_id: projectId, user_id: userId } =
-                          request
-                        logger.err(
-                          { projectId, userId, err },
-                          'failed to save output files'
-                        )
-                      }
-
-                      const outputStage = outputStageTimer.done()
-                      timings.sync = syncStage
-                      timings.output = outputStage
-
-                      // Emit e2e compile time.
-                      timings.compileE2E = timerE2E.done()
-                      Metrics.timing(
-                        'compile-e2e-v2',
-                        timings.compileE2E,
-                        1,
-                        request.metricsOpts
-                      )
-
-                      if (stats['pdf-size']) {
-                        emitPdfStats(stats, timings, request)
-                      }
-
-                      callback(null, newOutputFiles, stats, timings)
-                    }
-                  )
-                }
-              )
-            }
-          )
-        }
+      const { outputFiles } = await OutputFileFinder.promises.findOutputFiles(
+        resourceList,
+        compileDir
       )
       )
+      error.outputFiles = outputFiles // return output files so user can check logs
     }
     }
+    throw error
+  }
+
+  // compile completed normally
+  let { stats, timings } = compileResult
+  stats = stats || {}
+  timings = timings || {}
+  Metrics.inc('compiles-succeeded', 1, request.metricsOpts)
+  for (const metricKey in stats) {
+    const metricValue = stats[metricKey]
+    Metrics.count(metricKey, metricValue, 1, request.metricsOpts)
+  }
+  for (const metricKey in timings) {
+    const metricValue = timings[metricKey]
+    Metrics.timing(metricKey, metricValue, 1, request.metricsOpts)
+  }
+  const loadavg = typeof os.loadavg === 'function' ? os.loadavg() : undefined
+  if (loadavg != null) {
+    Metrics.gauge('load-avg', loadavg[0])
+  }
+  const ts = compileTimer.done()
+  logger.debug(
+    {
+      projectId: request.project_id,
+      userId: request.user_id,
+      time_taken: ts,
+      stats,
+      timings,
+      loadavg,
+    },
+    'done compile'
   )
   )
-}
+  if (stats['latex-runs'] > 0) {
+    Metrics.histogram(
+      'avg-compile-per-pass-v2',
+      ts / stats['latex-runs'],
+      COMPILE_TIME_BUCKETS,
+      request.metricsOpts
+    )
+    Metrics.timing(
+      'avg-compile-per-pass-v2',
+      ts / stats['latex-runs'],
+      1,
+      request.metricsOpts
+    )
+  }
+  if (stats['latex-runs'] > 0 && timings['cpu-time'] > 0) {
+    Metrics.timing(
+      'run-compile-cpu-time-per-pass',
+      timings['cpu-time'] / stats['latex-runs'],
+      1,
+      request.metricsOpts
+    )
+  }
+  // Emit compile time.
+  timings.compile = ts
 
 
-function stopCompile(projectId, userId, callback) {
-  const compileName = getCompileName(projectId, userId)
-  LatexRunner.killLatex(compileName, callback)
-}
+  const outputStageTimer = new Metrics.Timer(
+    'process-output-files',
+    1,
+    request.metricsOpts
+  )
+
+  let { outputFiles } = await OutputFileFinder.promises.findOutputFiles(
+    resourceList,
+    compileDir
+  )
 
 
-function clearProject(projectId, userId, _callback) {
-  function callback(error) {
-    _callback(error)
-    _callback = function () {}
+  try {
+    outputFiles = await OutputCacheManager.promises.saveOutputFiles(
+      { request, stats, timings },
+      outputFiles,
+      compileDir,
+      outputDir
+    )
+  } catch (err) {
+    const { project_id: projectId, user_id: userId } = request
+    logger.err({ projectId, userId, err }, 'failed to save output files')
   }
   }
 
 
-  const compileDir = getCompileDir(projectId, userId)
+  const outputStage = outputStageTimer.done()
+  timings.sync = syncStage
+  timings.output = outputStage
 
 
-  _checkDirectory(compileDir, (err, exists) => {
-    if (err) {
-      return callback(err)
-    }
-    if (!exists) {
-      return callback()
-    } // skip removal if no directory present
+  // Emit e2e compile time.
+  timings.compileE2E = timerE2E.done()
+  Metrics.timing('compile-e2e-v2', timings.compileE2E, 1, request.metricsOpts)
 
 
-    const proc = childProcess.spawn('rm', ['-r', '-f', '--', compileDir])
+  if (stats['pdf-size']) {
+    emitPdfStats(stats, timings, request)
+  }
 
 
-    proc.on('error', callback)
+  return { outputFiles, stats, timings }
+}
 
 
-    let stderr = ''
-    proc.stderr.setEncoding('utf8').on('data', chunk => (stderr += chunk))
+async function stopCompile(projectId, userId) {
+  const compileName = getCompileName(projectId, userId)
+  await LatexRunner.promises.killLatex(compileName)
+}
 
 
-    proc.on('close', code => {
-      if (code === 0) {
-        callback(null)
-      } else {
-        callback(new Error(`rm -r ${compileDir} failed: ${stderr}`))
-      }
-    })
-  })
+async function clearProject(projectId, userId) {
+  const compileDir = getCompileDir(projectId, userId)
+
+  const exists = await _checkDirectory(compileDir)
+  if (!exists) {
+    // skip removal if no directory present
+    return
+  }
+
+  try {
+    await execFile('rm', ['-r', '-f', '--', compileDir])
+  } catch (err) {
+    OError.tag(err, `rm -r failed`, { compileDir, stderr: err.stderr })
+    throw err
+  }
 }
 }
 
 
-function _findAllDirs(callback) {
+async function _findAllDirs() {
   const root = Settings.path.compilesDir
   const root = Settings.path.compilesDir
-  fs.readdir(root, (err, files) => {
-    if (err) {
-      return callback(err)
-    }
-    const allDirs = files.map(file => Path.join(root, file))
-    callback(null, allDirs)
-  })
+  const files = await fsPromises.readdir(root)
+  const allDirs = files.map(file => Path.join(root, file))
+  return allDirs
 }
 }
 
 
-function clearExpiredProjects(maxCacheAgeMs, callback) {
+async function clearExpiredProjects(maxCacheAgeMs) {
   const now = Date.now()
   const now = Date.now()
-  // action for each directory
-  const expireIfNeeded = (checkDir, cb) =>
-    fs.stat(checkDir, (err, stats) => {
-      if (err) {
-        return cb()
-      } // ignore errors checking directory
-      const age = now - stats.mtime
-      const hasExpired = age > maxCacheAgeMs
-      if (hasExpired) {
-        fse.remove(checkDir, cb)
-      } else {
-        cb()
-      }
-    })
-  // iterate over all project directories
-  _findAllDirs((error, allDirs) => {
-    if (error) {
-      return callback()
+  const dirs = await _findAllDirs()
+  for (const dir of dirs) {
+    let stats
+    try {
+      stats = await fsPromises.stat(dir)
+    } catch (err) {
+      // ignore errors checking directory
+      continue
     }
     }
-    async.eachSeries(allDirs, expireIfNeeded, callback)
-  })
+
+    const age = now - stats.mtime
+    const hasExpired = age > maxCacheAgeMs
+    if (hasExpired) {
+      await fse.remove(dir)
+    }
+  }
 }
 }
 
 
-function _checkDirectory(compileDir, callback) {
-  fs.lstat(compileDir, (err, stats) => {
-    if (err && err.code === 'ENOENT') {
-      callback(null, false) //  directory does not exist
-    } else if (err) {
-      logger.err(
-        { dir: compileDir, err },
-        'error on stat of project directory for removal'
-      )
-      callback(err)
-    } else if (!stats.isDirectory()) {
-      logger.err(
-        { dir: compileDir, stats },
-        'bad project directory for removal'
-      )
-      callback(new Error('project directory is not directory'))
-    } else {
-      // directory exists
-      callback(null, true)
+async function _checkDirectory(compileDir) {
+  let stats
+  try {
+    stats = await fsPromises.lstat(compileDir)
+  } catch (err) {
+    if (err.code === 'ENOENT') {
+      //  directory does not exist
+      return false
     }
     }
-  })
+    OError.tag(err, 'error on stat of project directory for removal', {
+      dir: compileDir,
+    })
+    throw err
+  }
+  if (!stats.isDirectory()) {
+    throw new OError('project directory is not directory', {
+      dir: compileDir,
+      stats,
+    })
+  }
+  return true
 }
 }
 
 
-function syncFromCode(
+async function syncFromCode(
   projectId,
   projectId,
   userId,
   userId,
   filename,
   filename,
   line,
   line,
   column,
   column,
-  imageName,
-  callback
+  imageName
 ) {
 ) {
   // If LaTeX was run in a virtual environment, the file path that synctex expects
   // If LaTeX was run in a virtual environment, the file path that synctex expects
   // might not match the file path on the host. The .synctex.gz file however, will be accessed
   // might not match the file path on the host. The .synctex.gz file however, will be accessed
@@ -473,19 +408,15 @@ function syncFromCode(
     '-o',
     '-o',
     outputFilePath,
     outputFilePath,
   ]
   ]
-  _runSynctex(projectId, userId, command, imageName, (error, stdout) => {
-    if (error) {
-      return callback(error)
-    }
-    logger.debug(
-      { projectId, userId, filename, line, column, command, stdout },
-      'synctex code output'
-    )
-    callback(null, SynctexOutputParser.parseViewOutput(stdout))
-  })
+  const stdout = await _runSynctex(projectId, userId, command, imageName)
+  logger.debug(
+    { projectId, userId, filename, line, column, command, stdout },
+    'synctex code output'
+  )
+  return SynctexOutputParser.parseViewOutput(stdout)
 }
 }
 
 
-function syncFromPdf(projectId, userId, page, h, v, imageName, callback) {
+async function syncFromPdf(projectId, userId, page, h, v, imageName) {
   const compileName = getCompileName(projectId, userId)
   const compileName = getCompileName(projectId, userId)
   const baseDir = Settings.path.synctexBaseDir(compileName)
   const baseDir = Settings.path.synctexBaseDir(compileName)
   const outputFilePath = `${baseDir}/output.pdf`
   const outputFilePath = `${baseDir}/output.pdf`
@@ -495,76 +426,64 @@ function syncFromPdf(projectId, userId, page, h, v, imageName, callback) {
     '-o',
     '-o',
     `${page}:${h}:${v}:${outputFilePath}`,
     `${page}:${h}:${v}:${outputFilePath}`,
   ]
   ]
-  _runSynctex(projectId, userId, command, imageName, (error, stdout) => {
-    if (error != null) {
-      return callback(error)
-    }
-    logger.debug(
-      { projectId, userId, page, h, v, stdout },
-      'synctex pdf output'
-    )
-    callback(null, SynctexOutputParser.parseEditOutput(stdout, baseDir))
-  })
+  const stdout = await _runSynctex(projectId, userId, command, imageName)
+  logger.debug({ projectId, userId, page, h, v, stdout }, 'synctex pdf output')
+  return SynctexOutputParser.parseEditOutput(stdout, baseDir)
 }
 }
 
 
-function _checkFileExists(dir, filename, callback) {
-  const file = Path.join(dir, filename)
-  fs.stat(dir, (error, stats) => {
-    if (error && error.code === 'ENOENT') {
-      return callback(new Errors.NotFoundError('no output directory'))
+async function _checkFileExists(dir, filename) {
+  try {
+    await fsPromises.stat(dir)
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      throw new Errors.NotFoundError('no output directory')
     }
     }
-    if (error) {
-      return callback(error)
+    throw error
+  }
+
+  const file = Path.join(dir, filename)
+  let stats
+  try {
+    stats = await fsPromises.stat(file)
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      throw new Errors.NotFoundError('no output file')
     }
     }
-    fs.stat(file, (error, stats) => {
-      if (error && error.code === 'ENOENT') {
-        return callback(new Errors.NotFoundError('no output file'))
-      }
-      if (error) {
-        return callback(error)
-      }
-      if (!stats.isFile()) {
-        return callback(new Error('not a file'))
-      }
-      callback()
-    })
-  })
+  }
+  if (!stats.isFile()) {
+    throw new Error('not a file')
+  }
 }
 }
 
 
-function _runSynctex(projectId, userId, command, imageName, callback) {
+async function _runSynctex(projectId, userId, command, imageName) {
   const directory = getCompileDir(projectId, userId)
   const directory = getCompileDir(projectId, userId)
   const timeout = 60 * 1000 // increased to allow for large projects
   const timeout = 60 * 1000 // increased to allow for large projects
   const compileName = getCompileName(projectId, userId)
   const compileName = getCompileName(projectId, userId)
   const compileGroup = 'synctex'
   const compileGroup = 'synctex'
   const defaultImageName =
   const defaultImageName =
     Settings.clsi && Settings.clsi.docker && Settings.clsi.docker.image
     Settings.clsi && Settings.clsi.docker && Settings.clsi.docker.image
-  _checkFileExists(directory, 'output.synctex.gz', error => {
-    if (error) {
-      return callback(error)
-    }
-    CommandRunner.run(
+  await _checkFileExists(directory, 'output.synctex.gz')
+  try {
+    const output = await CommandRunner.promises.run(
       compileName,
       compileName,
       command,
       command,
       directory,
       directory,
       imageName || defaultImageName,
       imageName || defaultImageName,
       timeout,
       timeout,
       {},
       {},
-      compileGroup,
-      (error, output) => {
-        if (error) {
-          logger.err(
-            { err: error, command, projectId, userId },
-            'error running synctex'
-          )
-          return callback(error)
-        }
-        callback(null, output.stdout)
-      }
+      compileGroup
     )
     )
-  })
+    return output.stdout
+  } catch (error) {
+    throw OError.tag(error, 'error running synctex', {
+      command,
+      projectId,
+      userId,
+    })
+  }
 }
 }
 
 
-function wordcount(projectId, userId, filename, image, callback) {
+async function wordcount(projectId, userId, filename, image) {
   logger.debug({ projectId, userId, filename, image }, 'running wordcount')
   logger.debug({ projectId, userId, filename, image }, 'running wordcount')
   const filePath = `$COMPILE_DIR/${filename}`
   const filePath = `$COMPILE_DIR/${filename}`
   const command = [
   const command = [
@@ -578,49 +497,43 @@ function wordcount(projectId, userId, filename, image, callback) {
   const timeout = 60 * 1000
   const timeout = 60 * 1000
   const compileName = getCompileName(projectId, userId)
   const compileName = getCompileName(projectId, userId)
   const compileGroup = 'wordcount'
   const compileGroup = 'wordcount'
-  fse.ensureDir(compileDir, error => {
-    if (error) {
-      logger.err(
-        { error, projectId, userId, filename },
-        'error ensuring dir for sync from code'
-      )
-      return callback(error)
-    }
-    CommandRunner.run(
-      compileName,
+  try {
+    await fse.ensureDir(compileDir)
+  } catch (err) {
+    throw OError.tag(err, 'error ensuring dir for wordcount', {
+      projectId,
+      userId,
+      filename,
+    })
+  }
+  await CommandRunner.promises.run(
+    compileName,
+    command,
+    compileDir,
+    image,
+    timeout,
+    {},
+    compileGroup
+  )
+
+  let stdout
+  try {
+    stdout = await fsPromises.readFile(
+      compileDir + '/' + filename + '.wc',
+      'utf-8'
+    )
+  } catch (err) {
+    throw OError.tag(err, 'error reading word count output', {
       command,
       command,
       compileDir,
       compileDir,
-      image,
-      timeout,
-      {},
-      compileGroup,
-      error => {
-        if (error) {
-          return callback(error)
-        }
-        fs.readFile(
-          compileDir + '/' + filename + '.wc',
-          'utf-8',
-          (err, stdout) => {
-            if (err) {
-              // call it node_err so sentry doesn't use random path error as unique id so it can't be ignored
-              logger.err(
-                { node_err: err, command, compileDir, projectId, userId },
-                'error reading word count output'
-              )
-              return callback(err)
-            }
-            const results = _parseWordcountFromOutput(stdout)
-            logger.debug(
-              { projectId, userId, wordcount: results },
-              'word count results'
-            )
-            callback(null, results)
-          }
-        )
-      }
-    )
-  })
+      projectId,
+      userId,
+    })
+  }
+
+  const results = _parseWordcountFromOutput(stdout)
+  logger.debug({ projectId, userId, wordcount: results }, 'word count results')
+  return results
 }
 }
 
 
 function _parseWordcountFromOutput(output) {
 function _parseWordcountFromOutput(output) {
@@ -675,11 +588,20 @@ function _parseWordcountFromOutput(output) {
 }
 }
 
 
 module.exports = {
 module.exports = {
-  doCompileWithLock,
-  stopCompile,
-  clearProject,
-  clearExpiredProjects,
-  syncFromCode,
-  syncFromPdf,
-  wordcount,
+  doCompileWithLock: callbackify(doCompileWithLock),
+  stopCompile: callbackify(stopCompile),
+  clearProject: callbackify(clearProject),
+  clearExpiredProjects: callbackify(clearExpiredProjects),
+  syncFromCode: callbackify(syncFromCode),
+  syncFromPdf: callbackify(syncFromPdf),
+  wordcount: callbackify(wordcount),
+  promises: {
+    doCompileWithLock,
+    stopCompile,
+    clearProject,
+    clearExpiredProjects,
+    syncFromCode,
+    syncFromPdf,
+    wordcount,
+  },
 }
 }

+ 5 - 0
services/clsi/app/js/DockerRunner.js

@@ -1,3 +1,4 @@
+const { promisify } = require('util')
 const Settings = require('@overleaf/settings')
 const Settings = require('@overleaf/settings')
 const logger = require('@overleaf/logger')
 const logger = require('@overleaf/logger')
 const Docker = require('dockerode')
 const Docker = require('dockerode')
@@ -617,3 +618,7 @@ const DockerRunner = {
 DockerRunner.startContainerMonitor()
 DockerRunner.startContainerMonitor()
 
 
 module.exports = DockerRunner
 module.exports = DockerRunner
+module.exports.promises = {
+  run: promisify(DockerRunner.run),
+  kill: promisify(DockerRunner.kill),
+}

+ 5 - 0
services/clsi/app/js/DraftModeManager.js

@@ -12,6 +12,7 @@
  */
  */
 let DraftModeManager
 let DraftModeManager
 const fs = require('fs')
 const fs = require('fs')
+const { promisify } = require('util')
 const logger = require('@overleaf/logger')
 const logger = require('@overleaf/logger')
 
 
 module.exports = DraftModeManager = {
 module.exports = DraftModeManager = {
@@ -54,3 +55,7 @@ module.exports = DraftModeManager = {
     )
     )
   },
   },
 }
 }
+
+module.exports.promises = {
+  injectDraftMode: promisify(DraftModeManager.injectDraftMode),
+}

+ 14 - 0
services/clsi/app/js/LatexRunner.js

@@ -1,4 +1,5 @@
 const Path = require('path')
 const Path = require('path')
+const { promisify } = require('util')
 const Settings = require('@overleaf/settings')
 const Settings = require('@overleaf/settings')
 const logger = require('@overleaf/logger')
 const logger = require('@overleaf/logger')
 const CommandRunner = require('./CommandRunner')
 const CommandRunner = require('./CommandRunner')
@@ -192,4 +193,17 @@ function _buildLatexCommand(mainFile, opts = {}) {
 module.exports = {
 module.exports = {
   runLatex,
   runLatex,
   killLatex,
   killLatex,
+  promises: {
+    runLatex: (projectId, options) =>
+      new Promise((resolve, reject) => {
+        runLatex(projectId, options, (err, output, stats, timing) => {
+          if (err) {
+            reject(err)
+          } else {
+            resolve({ output, stats, timing })
+          }
+        })
+      }),
+    killLatex: promisify(killLatex),
+  },
 }
 }

+ 6 - 0
services/clsi/app/js/LocalCommandRunner.js

@@ -14,6 +14,7 @@
  */
  */
 let CommandRunner
 let CommandRunner
 const { spawn } = require('child_process')
 const { spawn } = require('child_process')
+const { promisify } = require('util')
 const _ = require('lodash')
 const _ = require('lodash')
 const logger = require('@overleaf/logger')
 const logger = require('@overleaf/logger')
 
 
@@ -100,3 +101,8 @@ module.exports = CommandRunner = {
     return callback()
     return callback()
   },
   },
 }
 }
+
+module.exports.promises = {
+  run: promisify(CommandRunner.run),
+  kill: promisify(CommandRunner.kill),
+}

+ 56 - 66
services/clsi/app/js/LockManager.js

@@ -1,71 +1,61 @@
-/* eslint-disable
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS101: Remove unnecessary use of Array.from
- * DS102: Remove unnecessary code created because of implicit returns
- * DS207: Consider shorter variations of null checks
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-let LockManager
-const Settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-const Lockfile = require('lockfile') // from https://github.com/npm/lockfile
+const { promisify } = require('util')
+const OError = require('@overleaf/o-error')
+const Lockfile = require('lockfile')
 const Errors = require('./Errors')
 const Errors = require('./Errors')
-const fs = require('fs')
+const fsPromises = require('fs/promises')
 const Path = require('path')
 const Path = require('path')
-module.exports = LockManager = {
-  LOCK_TEST_INTERVAL: 1000, // 50ms between each test of the lock
-  MAX_LOCK_WAIT_TIME: 15000, // 10s maximum time to spend trying to get the lock
-  LOCK_STALE: 5 * 60 * 1000, // 5 mins time until lock auto expires
 
 
-  runWithLock(path, runner, callback) {
-    if (callback == null) {
-      callback = function () {}
-    }
-    const lockOpts = {
-      wait: this.MAX_LOCK_WAIT_TIME,
-      pollPeriod: this.LOCK_TEST_INTERVAL,
-      stale: this.LOCK_STALE,
+const LOCK_OPTS = {
+  pollPeriod: 1000, // 1s between each test of the lock
+  wait: 15000, // 15s maximum time to spend trying to get the lock
+  stale: 5 * 60 * 1000, // 5 mins time until lock auto expires
+}
+
+const PromisifiedLockfile = {
+  lock: promisify(Lockfile.lock),
+  unlock: promisify(Lockfile.unlock),
+}
+
+async function acquire(path) {
+  try {
+    await PromisifiedLockfile.lock(path, LOCK_OPTS)
+  } catch (err) {
+    if (err.code === 'EEXIST') {
+      throw new Errors.AlreadyCompilingError('compile in progress')
+    } else {
+      const dir = Path.dirname(path)
+      const [statLock, statDir, readdirDir] = await Promise.allSettled([
+        fsPromises.lstat(path),
+        fsPromises.lstat(dir),
+        fsPromises.readdir(dir),
+      ])
+      OError.tag(err, 'unable to get lock', {
+        statLock: unwrapPromiseResult(statLock),
+        statDir: unwrapPromiseResult(statDir),
+        readdirDir: unwrapPromiseResult(readdirDir),
+      })
+      throw err
     }
     }
-    return Lockfile.lock(path, lockOpts, function (error) {
-      if ((error != null ? error.code : undefined) === 'EEXIST') {
-        return callback(new Errors.AlreadyCompilingError('compile in progress'))
-      } else if (error != null) {
-        return fs.lstat(path, (statLockErr, statLock) =>
-          fs.lstat(Path.dirname(path), (statDirErr, statDir) =>
-            fs.readdir(Path.dirname(path), function (readdirErr, readdirDir) {
-              logger.err(
-                {
-                  error,
-                  path,
-                  statLock,
-                  statLockErr,
-                  statDir,
-                  statDirErr,
-                  readdirErr,
-                  readdirDir,
-                },
-                'unable to get lock'
-              )
-              return callback(error)
-            })
-          )
-        )
-      } else {
-        return runner((error1, ...args) =>
-          Lockfile.unlock(path, function (error2) {
-            error = error1 || error2
-            if (error != null) {
-              return callback(error)
-            }
-            return callback(null, ...Array.from(args))
-          })
-        )
-      }
-    })
-  },
+  }
+  return new Lock(path)
 }
 }
+
+class Lock {
+  constructor(path) {
+    this._path = path
+  }
+
+  async release() {
+    await PromisifiedLockfile.unlock(this._path)
+  }
+}
+
+function unwrapPromiseResult(result) {
+  if (result.status === 'fulfilled') {
+    return result.value
+  } else {
+    return result.reason
+  }
+}
+
+module.exports = { acquire }

+ 1 - 0
services/clsi/app/js/OutputCacheManager.js

@@ -695,6 +695,7 @@ function __guard__(value, transform) {
 
 
 OutputCacheManager.promises = {
 OutputCacheManager.promises = {
   expireOutputFiles: promisify(OutputCacheManager.expireOutputFiles),
   expireOutputFiles: promisify(OutputCacheManager.expireOutputFiles),
+  saveOutputFiles: promisify(OutputCacheManager.saveOutputFiles),
   saveOutputFilesInBuildDir: promisify(
   saveOutputFilesInBuildDir: promisify(
     OutputCacheManager.saveOutputFilesInBuildDir
     OutputCacheManager.saveOutputFilesInBuildDir
   ),
   ),

+ 17 - 0
services/clsi/app/js/OutputFileFinder.js

@@ -76,3 +76,20 @@ module.exports = OutputFileFinder = {
     })
     })
   },
   },
 }
 }
+
+module.exports.promises = {
+  findOutputFiles: (resources, directory) =>
+    new Promise((resolve, reject) => {
+      OutputFileFinder.findOutputFiles(
+        resources,
+        directory,
+        (err, outputFiles, allFiles) => {
+          if (err) {
+            reject(err)
+          } else {
+            resolve({ outputFiles, allFiles })
+          }
+        }
+      )
+    }),
+}

+ 58 - 34
services/clsi/app/js/ResourceWriter.js

@@ -14,6 +14,7 @@
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
 let ResourceWriter
 let ResourceWriter
+const { promisify } = require('util')
 const UrlCache = require('./UrlCache')
 const UrlCache = require('./UrlCache')
 const Path = require('path')
 const Path = require('path')
 const fs = require('fs')
 const fs = require('fs')
@@ -85,22 +86,26 @@ module.exports = ResourceWriter = {
       if (error != null) {
       if (error != null) {
         return callback(error)
         return callback(error)
       }
       }
-      this.saveAllResourcesToDisk(request, basePath, function (error) {
-        if (error != null) {
-          return callback(error)
-        }
-        return ResourceStateManager.saveProjectState(
-          request.syncState,
-          request.resources,
-          basePath,
-          function (error) {
-            if (error != null) {
-              return callback(error)
-            }
-            return callback(null, request.resources)
+      ResourceWriter.saveAllResourcesToDisk(
+        request,
+        basePath,
+        function (error) {
+          if (error != null) {
+            return callback(error)
           }
           }
-        )
-      })
+          return ResourceStateManager.saveProjectState(
+            request.syncState,
+            request.resources,
+            basePath,
+            function (error) {
+              if (error != null) {
+                return callback(error)
+              }
+              return callback(null, request.resources)
+            }
+          )
+        }
+      )
     })
     })
   },
   },
 
 
@@ -108,14 +113,19 @@ module.exports = ResourceWriter = {
     if (callback == null) {
     if (callback == null) {
       callback = function () {}
       callback = function () {}
     }
     }
-    return this._createDirectory(basePath, error => {
+    return ResourceWriter._createDirectory(basePath, error => {
       if (error != null) {
       if (error != null) {
         return callback(error)
         return callback(error)
       }
       }
       const jobs = Array.from(resources).map(resource =>
       const jobs = Array.from(resources).map(resource =>
         (resource => {
         (resource => {
           return callback =>
           return callback =>
-            this._writeResourceToDisk(project_id, resource, basePath, callback)
+            ResourceWriter._writeResourceToDisk(
+              project_id,
+              resource,
+              basePath,
+              callback
+            )
         })(resource)
         })(resource)
       )
       )
       return async.parallelLimit(jobs, parallelFileDownloads, callback)
       return async.parallelLimit(jobs, parallelFileDownloads, callback)
@@ -126,28 +136,33 @@ module.exports = ResourceWriter = {
     if (callback == null) {
     if (callback == null) {
       callback = function () {}
       callback = function () {}
     }
     }
-    return this._createDirectory(basePath, error => {
+    return ResourceWriter._createDirectory(basePath, error => {
       if (error != null) {
       if (error != null) {
         return callback(error)
         return callback(error)
       }
       }
       const { project_id, resources } = request
       const { project_id, resources } = request
-      this._removeExtraneousFiles(request, resources, basePath, error => {
-        if (error != null) {
-          return callback(error)
+      ResourceWriter._removeExtraneousFiles(
+        request,
+        resources,
+        basePath,
+        error => {
+          if (error != null) {
+            return callback(error)
+          }
+          const jobs = Array.from(resources).map(resource =>
+            (resource => {
+              return callback =>
+                ResourceWriter._writeResourceToDisk(
+                  project_id,
+                  resource,
+                  basePath,
+                  callback
+                )
+            })(resource)
+          )
+          return async.parallelLimit(jobs, parallelFileDownloads, callback)
         }
         }
-        const jobs = Array.from(resources).map(resource =>
-          (resource => {
-            return callback =>
-              this._writeResourceToDisk(
-                project_id,
-                resource,
-                basePath,
-                callback
-              )
-          })(resource)
-        )
-        return async.parallelLimit(jobs, parallelFileDownloads, callback)
-      })
+      )
     })
     })
   },
   },
 
 
@@ -356,3 +371,12 @@ module.exports = ResourceWriter = {
     }
     }
   },
   },
 }
 }
+
+module.exports.promises = {
+  syncResourcesToDisk: promisify(ResourceWriter.syncResourcesToDisk),
+  saveIncrementalResourcesToDisk: promisify(
+    ResourceWriter.saveIncrementalResourcesToDisk
+  ),
+  saveAllResourcesToDisk: promisify(ResourceWriter.saveAllResourcesToDisk),
+  checkPath: promisify(ResourceWriter.checkPath),
+}

+ 6 - 0
services/clsi/app/js/TikzManager.js

@@ -13,6 +13,7 @@
 let TikzManager
 let TikzManager
 const fs = require('fs')
 const fs = require('fs')
 const Path = require('path')
 const Path = require('path')
+const { promisify } = require('util')
 const ResourceWriter = require('./ResourceWriter')
 const ResourceWriter = require('./ResourceWriter')
 const SafeReader = require('./SafeReader')
 const SafeReader = require('./SafeReader')
 const logger = require('@overleaf/logger')
 const logger = require('@overleaf/logger')
@@ -101,3 +102,8 @@ module.exports = TikzManager = {
     )
     )
   },
   },
 }
 }
+
+module.exports.promises = {
+  checkMainFile: promisify(TikzManager.checkMainFile),
+  injectOutputFile: promisify(TikzManager.injectOutputFile),
+}

+ 1 - 0
services/clsi/package.json

@@ -39,6 +39,7 @@
     "chai": "^4.3.6",
     "chai": "^4.3.6",
     "chai-as-promised": "^7.1.1",
     "chai-as-promised": "^7.1.1",
     "mocha": "^8.4.0",
     "mocha": "^8.4.0",
+    "mock-fs": "^5.1.2",
     "sandboxed-module": "^2.0.4",
     "sandboxed-module": "^2.0.4",
     "sinon": "~9.0.1",
     "sinon": "~9.0.1",
     "sinon-chai": "^3.7.0",
     "sinon-chai": "^3.7.0",

+ 2 - 0
services/clsi/test/setup.js

@@ -1,10 +1,12 @@
 const chai = require('chai')
 const chai = require('chai')
 const sinonChai = require('sinon-chai')
 const sinonChai = require('sinon-chai')
+const chaiAsPromised = require('chai-as-promised')
 const SandboxedModule = require('sandboxed-module')
 const SandboxedModule = require('sandboxed-module')
 
 
 // Setup chai
 // Setup chai
 chai.should()
 chai.should()
 chai.use(sinonChai)
 chai.use(sinonChai)
+chai.use(chaiAsPromised)
 
 
 // Global SandboxedModule settings
 // Global SandboxedModule settings
 SandboxedModule.configure({
 SandboxedModule.configure({

+ 20 - 12
services/clsi/test/unit/js/CompileControllerTests.js

@@ -111,9 +111,11 @@ describe('CompileController', function () {
 
 
     describe('successfully', function () {
     describe('successfully', function () {
       beforeEach(function () {
       beforeEach(function () {
-        this.CompileManager.doCompileWithLock = sinon
-          .stub()
-          .yields(null, this.output_files, this.stats, this.timings)
+        this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
+          outputFiles: this.output_files,
+          stats: this.stats,
+          timings: this.timings,
+        })
         this.CompileController.compile(this.req, this.res)
         this.CompileController.compile(this.req, this.res)
       })
       })
 
 
@@ -156,9 +158,11 @@ describe('CompileController', function () {
     describe('without a outputUrlPrefix', function () {
     describe('without a outputUrlPrefix', function () {
       beforeEach(function () {
       beforeEach(function () {
         this.Settings.apis.clsi.outputUrlPrefix = ''
         this.Settings.apis.clsi.outputUrlPrefix = ''
-        this.CompileManager.doCompileWithLock = sinon
-          .stub()
-          .yields(null, this.output_files, this.stats, this.timings)
+        this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
+          outputFiles: this.output_files,
+          stats: this.stats,
+          timings: this.timings,
+        })
         this.CompileController.compile(this.req, this.res)
         this.CompileController.compile(this.req, this.res)
       })
       })
 
 
@@ -196,9 +200,11 @@ describe('CompileController', function () {
             build: 1234,
             build: 1234,
           },
           },
         ]
         ]
-        this.CompileManager.doCompileWithLock = sinon
-          .stub()
-          .yields(null, this.output_files, this.stats, this.timings)
+        this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
+          outputFiles: this.output_files,
+          stats: this.stats,
+          timings: this.timings,
+        })
         this.CompileController.compile(this.req, this.res)
         this.CompileController.compile(this.req, this.res)
       })
       })
 
 
@@ -237,9 +243,11 @@ describe('CompileController', function () {
             build: 1234,
             build: 1234,
           },
           },
         ]
         ]
-        this.CompileManager.doCompileWithLock = sinon
-          .stub()
-          .yields(null, this.output_files, this.stats, this.timings)
+        this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
+          outputFiles: this.output_files,
+          stats: this.stats,
+          timings: this.timings,
+        })
         this.CompileController.compile(this.req, this.res)
         this.CompileController.compile(this.req, this.res)
       })
       })
 
 

+ 213 - 221
services/clsi/test/unit/js/CompileManagerTests.js

@@ -1,14 +1,14 @@
 const SandboxedModule = require('sandboxed-module')
 const SandboxedModule = require('sandboxed-module')
+const { expect } = require('chai')
 const sinon = require('sinon')
 const sinon = require('sinon')
-const modulePath = require('path').join(
+
+const MODULE_PATH = require('path').join(
   __dirname,
   __dirname,
   '../../../app/js/CompileManager'
   '../../../app/js/CompileManager'
 )
 )
-const { EventEmitter } = require('events')
 
 
 describe('CompileManager', function () {
 describe('CompileManager', function () {
   beforeEach(function () {
   beforeEach(function () {
-    this.callback = sinon.stub()
     this.projectId = 'project-id-123'
     this.projectId = 'project-id-123'
     this.userId = '1234'
     this.userId = '1234'
     this.resources = 'mock-resources'
     this.resources = 'mock-resources'
@@ -40,22 +40,25 @@ describe('CompileManager', function () {
     this.compileDir = `${this.compileBaseDir}/${this.projectId}-${this.userId}`
     this.compileDir = `${this.compileBaseDir}/${this.projectId}-${this.userId}`
     this.outputDir = `${this.outputBaseDir}/${this.projectId}-${this.userId}`
     this.outputDir = `${this.outputBaseDir}/${this.projectId}-${this.userId}`
 
 
-    this.proc = new EventEmitter()
-    this.proc.stdout = new EventEmitter()
-    this.proc.stderr = new EventEmitter()
-    this.proc.stderr.setEncoding = sinon.stub().returns(this.proc.stderr)
-
     this.LatexRunner = {
     this.LatexRunner = {
-      runLatex: sinon.stub().yields(),
+      promises: {
+        runLatex: sinon.stub().resolves({}),
+      },
     }
     }
     this.ResourceWriter = {
     this.ResourceWriter = {
-      syncResourcesToDisk: sinon.stub().yields(null, this.resources),
+      promises: {
+        syncResourcesToDisk: sinon.stub().resolves(this.resources),
+      },
     }
     }
     this.OutputFileFinder = {
     this.OutputFileFinder = {
-      findOutputFiles: sinon.stub().yields(null, this.outputFiles),
+      promises: {
+        findOutputFiles: sinon.stub().resolves(this.outputFiles),
+      },
     }
     }
     this.OutputCacheManager = {
     this.OutputCacheManager = {
-      saveOutputFiles: sinon.stub().yields(null, this.buildFiles),
+      promises: {
+        saveOutputFiles: sinon.stub().resolves(this.buildFiles),
+      },
     }
     }
     this.Settings = {
     this.Settings = {
       path: {
       path: {
@@ -74,37 +77,44 @@ describe('CompileManager', function () {
       .returns(this.compileDir)
       .returns(this.compileDir)
     this.child_process = {
     this.child_process = {
       exec: sinon.stub(),
       exec: sinon.stub(),
-      spawn: sinon.stub().returns(this.proc),
+      execFile: sinon.stub().yields(),
     }
     }
     this.CommandRunner = {
     this.CommandRunner = {
-      run: sinon.stub().yields(null, { stdout: this.commandOutput }),
+      promises: {
+        run: sinon.stub().resolves({ stdout: this.commandOutput }),
+      },
     }
     }
     this.DraftModeManager = {
     this.DraftModeManager = {
-      injectDraftMode: sinon.stub().yields(),
+      promises: {
+        injectDraftMode: sinon.stub().resolves(),
+      },
     }
     }
     this.TikzManager = {
     this.TikzManager = {
-      checkMainFile: sinon.stub().yields(null, false),
+      promises: {
+        checkMainFile: sinon.stub().resolves(false),
+      },
+    }
+    this.lock = {
+      release: sinon.stub().resolves(),
     }
     }
     this.LockManager = {
     this.LockManager = {
-      runWithLock: sinon.stub().callsFake((lockFile, runner, callback) => {
-        runner((err, ...result) => callback(err, ...result))
-      }),
+      acquire: sinon.stub().resolves(this.lock),
     }
     }
     this.SynctexOutputParser = {
     this.SynctexOutputParser = {
       parseViewOutput: sinon.stub(),
       parseViewOutput: sinon.stub(),
       parseEditOutput: sinon.stub(),
       parseEditOutput: sinon.stub(),
     }
     }
 
 
-    this.fs = {
+    this.fsPromises = {
       lstat: sinon.stub(),
       lstat: sinon.stub(),
       stat: sinon.stub(),
       stat: sinon.stub(),
       readFile: sinon.stub(),
       readFile: sinon.stub(),
     }
     }
     this.fse = {
     this.fse = {
-      ensureDir: sinon.stub().yields(),
+      ensureDir: sinon.stub().resolves(),
     }
     }
 
 
-    this.CompileManager = SandboxedModule.require(modulePath, {
+    this.CompileManager = SandboxedModule.require(MODULE_PATH, {
       requires: {
       requires: {
         './LatexRunner': this.LatexRunner,
         './LatexRunner': this.LatexRunner,
         './ResourceWriter': this.ResourceWriter,
         './ResourceWriter': this.ResourceWriter,
@@ -117,7 +127,7 @@ describe('CompileManager', function () {
         './TikzManager': this.TikzManager,
         './TikzManager': this.TikzManager,
         './LockManager': this.LockManager,
         './LockManager': this.LockManager,
         './SynctexOutputParser': this.SynctexOutputParser,
         './SynctexOutputParser': this.SynctexOutputParser,
-        fs: this.fs,
+        'fs/promises': this.fsPromises,
         'fs-extra': this.fse,
         'fs-extra': this.fse,
       },
       },
     })
     })
@@ -141,45 +151,44 @@ describe('CompileManager', function () {
     })
     })
 
 
     describe('when the project is locked', function () {
     describe('when the project is locked', function () {
-      beforeEach(function () {
-        this.error = new Error('locked')
-        this.LockManager.runWithLock.callsFake((lockFile, runner, callback) => {
-          callback(this.error)
-        })
-        this.CompileManager.doCompileWithLock(this.request, this.callback)
+      beforeEach(async function () {
+        const error = new Error('locked')
+        this.LockManager.acquire.rejects(error)
+        await expect(
+          this.CompileManager.promises.doCompileWithLock(this.request)
+        ).to.be.rejectedWith(error)
       })
       })
 
 
       it('should ensure that the compile directory exists', function () {
       it('should ensure that the compile directory exists', function () {
-        this.fse.ensureDir.calledWith(this.compileDir).should.equal(true)
+        expect(this.fse.ensureDir).to.have.been.calledWith(this.compileDir)
       })
       })
 
 
       it('should not run LaTeX', function () {
       it('should not run LaTeX', function () {
-        this.LatexRunner.runLatex.called.should.equal(false)
-      })
-
-      it('should call the callback with the error', function () {
-        this.callback.calledWithExactly(this.error).should.equal(true)
+        expect(this.LatexRunner.promises.runLatex).not.to.have.been.called
       })
       })
     })
     })
 
 
     describe('normally', function () {
     describe('normally', function () {
-      beforeEach(function () {
-        this.CompileManager.doCompileWithLock(this.request, this.callback)
+      beforeEach(async function () {
+        this.result = await this.CompileManager.promises.doCompileWithLock(
+          this.request
+        )
       })
       })
 
 
       it('should ensure that the compile directory exists', function () {
       it('should ensure that the compile directory exists', function () {
-        this.fse.ensureDir.calledWith(this.compileDir).should.equal(true)
+        expect(this.fse.ensureDir).to.have.been.calledWith(this.compileDir)
       })
       })
 
 
       it('should write the resources to disk', function () {
       it('should write the resources to disk', function () {
-        this.ResourceWriter.syncResourcesToDisk
-          .calledWith(this.request, this.compileDir)
-          .should.equal(true)
+        expect(
+          this.ResourceWriter.promises.syncResourcesToDisk
+        ).to.have.been.calledWith(this.request, this.compileDir)
       })
       })
 
 
       it('should run LaTeX', function () {
       it('should run LaTeX', function () {
-        this.LatexRunner.runLatex
-          .calledWith(`${this.projectId}-${this.userId}`, {
+        expect(this.LatexRunner.promises.runLatex).to.have.been.calledWith(
+          `${this.projectId}-${this.userId}`,
+          {
             directory: this.compileDir,
             directory: this.compileDir,
             mainFile: this.rootResourcePath,
             mainFile: this.rootResourcePath,
             compiler: this.compiler,
             compiler: this.compiler,
@@ -189,47 +198,49 @@ describe('CompileManager', function () {
             environment: this.env,
             environment: this.env,
             compileGroup: this.compileGroup,
             compileGroup: this.compileGroup,
             stopOnFirstError: this.request.stopOnFirstError,
             stopOnFirstError: this.request.stopOnFirstError,
-          })
-          .should.equal(true)
+          }
+        )
       })
       })
 
 
       it('should find the output files', function () {
       it('should find the output files', function () {
-        this.OutputFileFinder.findOutputFiles
-          .calledWith(this.resources, this.compileDir)
-          .should.equal(true)
+        expect(
+          this.OutputFileFinder.promises.findOutputFiles
+        ).to.have.been.calledWith(this.resources, this.compileDir)
       })
       })
 
 
       it('should return the output files', function () {
       it('should return the output files', function () {
-        this.callback.calledWith(null, this.buildFiles).should.equal(true)
+        expect(this.result.outputFiles).to.equal(this.buildFiles)
       })
       })
 
 
       it('should not inject draft mode by default', function () {
       it('should not inject draft mode by default', function () {
-        this.DraftModeManager.injectDraftMode.called.should.equal(false)
+        expect(this.DraftModeManager.promises.injectDraftMode).not.to.have.been
+          .called
       })
       })
     })
     })
 
 
     describe('with draft mode', function () {
     describe('with draft mode', function () {
-      beforeEach(function () {
+      beforeEach(async function () {
         this.request.draft = true
         this.request.draft = true
-        this.CompileManager.doCompileWithLock(this.request, this.callback)
+        await this.CompileManager.promises.doCompileWithLock(this.request)
       })
       })
 
 
       it('should inject the draft mode header', function () {
       it('should inject the draft mode header', function () {
-        this.DraftModeManager.injectDraftMode
-          .calledWith(this.compileDir + '/' + this.rootResourcePath)
-          .should.equal(true)
+        expect(
+          this.DraftModeManager.promises.injectDraftMode
+        ).to.have.been.calledWith(this.compileDir + '/' + this.rootResourcePath)
       })
       })
     })
     })
 
 
     describe('with a check option', function () {
     describe('with a check option', function () {
-      beforeEach(function () {
+      beforeEach(async function () {
         this.request.check = 'error'
         this.request.check = 'error'
-        this.CompileManager.doCompileWithLock(this.request, this.callback)
+        await this.CompileManager.promises.doCompileWithLock(this.request)
       })
       })
 
 
       it('should run chktex', function () {
       it('should run chktex', function () {
-        this.LatexRunner.runLatex
-          .calledWith(`${this.projectId}-${this.userId}`, {
+        expect(this.LatexRunner.promises.runLatex).to.have.been.calledWith(
+          `${this.projectId}-${this.userId}`,
+          {
             directory: this.compileDir,
             directory: this.compileDir,
             mainFile: this.rootResourcePath,
             mainFile: this.rootResourcePath,
             compiler: this.compiler,
             compiler: this.compiler,
@@ -243,21 +254,22 @@ describe('CompileManager', function () {
             },
             },
             compileGroup: this.compileGroup,
             compileGroup: this.compileGroup,
             stopOnFirstError: this.request.stopOnFirstError,
             stopOnFirstError: this.request.stopOnFirstError,
-          })
-          .should.equal(true)
+          }
+        )
       })
       })
     })
     })
 
 
     describe('with a knitr file and check options', function () {
     describe('with a knitr file and check options', function () {
-      beforeEach(function () {
+      beforeEach(async function () {
         this.request.rootResourcePath = 'main.Rtex'
         this.request.rootResourcePath = 'main.Rtex'
         this.request.check = 'error'
         this.request.check = 'error'
-        this.CompileManager.doCompileWithLock(this.request, this.callback)
+        await this.CompileManager.promises.doCompileWithLock(this.request)
       })
       })
 
 
       it('should not run chktex', function () {
       it('should not run chktex', function () {
-        this.LatexRunner.runLatex
-          .calledWith(`${this.projectId}-${this.userId}`, {
+        expect(this.LatexRunner.promises.runLatex).to.have.been.calledWith(
+          `${this.projectId}-${this.userId}`,
+          {
             directory: this.compileDir,
             directory: this.compileDir,
             mainFile: 'main.Rtex',
             mainFile: 'main.Rtex',
             compiler: this.compiler,
             compiler: this.compiler,
@@ -267,69 +279,58 @@ describe('CompileManager', function () {
             environment: this.env,
             environment: this.env,
             compileGroup: this.compileGroup,
             compileGroup: this.compileGroup,
             stopOnFirstError: this.request.stopOnFirstError,
             stopOnFirstError: this.request.stopOnFirstError,
-          })
-          .should.equal(true)
+          }
+        )
       })
       })
     })
     })
   })
   })
 
 
   describe('clearProject', function () {
   describe('clearProject', function () {
     describe('succesfully', function () {
     describe('succesfully', function () {
-      beforeEach(function () {
+      beforeEach(async function () {
         this.Settings.compileDir = 'compiles'
         this.Settings.compileDir = 'compiles'
-        this.fs.lstat.yields(null, {
+        this.fsPromises.lstat.resolves({
           isDirectory() {
           isDirectory() {
             return true
             return true
           },
           },
         })
         })
-        this.CompileManager.clearProject(
+        await this.CompileManager.promises.clearProject(
           this.projectId,
           this.projectId,
-          this.userId,
-          this.callback
+          this.userId
         )
         )
-        this.proc.emit('close', 0)
       })
       })
 
 
       it('should remove the project directory', function () {
       it('should remove the project directory', function () {
-        this.child_process.spawn
-          .calledWith('rm', ['-r', '-f', '--', this.compileDir])
-          .should.equal(true)
-      })
-
-      it('should call the callback', function () {
-        this.callback.called.should.equal(true)
+        expect(this.child_process.execFile).to.have.been.calledWith('rm', [
+          '-r',
+          '-f',
+          '--',
+          this.compileDir,
+        ])
       })
       })
     })
     })
 
 
     describe('with a non-success status code', function () {
     describe('with a non-success status code', function () {
-      beforeEach(function () {
+      beforeEach(async function () {
         this.Settings.compileDir = 'compiles'
         this.Settings.compileDir = 'compiles'
-        this.fs.lstat.yields(null, {
+        this.fsPromises.lstat.resolves({
           isDirectory() {
           isDirectory() {
             return true
             return true
           },
           },
         })
         })
-        this.CompileManager.clearProject(
-          this.projectId,
-          this.userId,
-          this.callback
-        )
-        this.proc.stderr.emit('data', (this.error = 'oops'))
-        this.proc.emit('close', 1)
+        this.child_process.execFile.yields(new Error('oops'))
+        await expect(
+          this.CompileManager.promises.clearProject(this.projectId, this.userId)
+        ).to.be.rejected
       })
       })
 
 
       it('should remove the project directory', function () {
       it('should remove the project directory', function () {
-        this.child_process.spawn
-          .calledWith('rm', ['-r', '-f', '--', this.compileDir])
-          .should.equal(true)
-      })
-
-      it('should call the callback with an error from the stderr', function () {
-        this.callback.calledWithExactly(sinon.match(Error)).should.equal(true)
-
-        this.callback.args[0][0].message.should.equal(
-          `rm -r ${this.compileDir} failed: ${this.error}`
-        )
+        expect(this.child_process.execFile).to.have.been.calledWith('rm', [
+          '-r',
+          '-f',
+          '--',
+          this.compileDir,
+        ])
       })
       })
     })
     })
   })
   })
@@ -348,7 +349,7 @@ describe('CompileManager', function () {
 
 
     describe('syncFromCode', function () {
     describe('syncFromCode', function () {
       beforeEach(function () {
       beforeEach(function () {
-        this.fs.stat.yields(null, {
+        this.fsPromises.stat.resolves({
           isFile() {
           isFile() {
             return true
             return true
           },
           },
@@ -357,63 +358,62 @@ describe('CompileManager', function () {
         this.SynctexOutputParser.parseViewOutput
         this.SynctexOutputParser.parseViewOutput
           .withArgs(this.commandOutput)
           .withArgs(this.commandOutput)
           .returns(this.records)
           .returns(this.records)
-        this.CompileManager.syncFromCode(
-          this.projectId,
-          this.userId,
-          this.filename,
-          this.line,
-          this.column,
-          '',
-          this.callback
-        )
       })
       })
 
 
-      it('should execute the synctex binary', function () {
-        const outputFilePath = `${this.compileDir}/output.pdf`
-        const inputFilePath = `${this.compileDir}/${this.filename}`
-        this.CommandRunner.run.should.have.been.calledWith(
-          `${this.projectId}-${this.userId}`,
-          [
-            'synctex',
-            'view',
-            '-i',
-            `${this.line}:${this.column}:${inputFilePath}`,
-            '-o',
-            outputFilePath,
-          ],
-          this.compileDir,
-          this.Settings.clsi.docker.image,
-          60000,
-          {}
-        )
-      })
+      describe('normal case', function () {
+        beforeEach(async function () {
+          this.result = await this.CompileManager.promises.syncFromCode(
+            this.projectId,
+            this.userId,
+            this.filename,
+            this.line,
+            this.column,
+            ''
+          )
+        })
 
 
-      it('should call the callback with the parsed output', function () {
-        this.callback.should.have.been.calledWith(
-          null,
-          sinon.match.array.deepEquals(this.records)
-        )
+        it('should execute the synctex binary', function () {
+          const outputFilePath = `${this.compileDir}/output.pdf`
+          const inputFilePath = `${this.compileDir}/${this.filename}`
+          expect(this.CommandRunner.promises.run).to.have.been.calledWith(
+            `${this.projectId}-${this.userId}`,
+            [
+              'synctex',
+              'view',
+              '-i',
+              `${this.line}:${this.column}:${inputFilePath}`,
+              '-o',
+              outputFilePath,
+            ],
+            this.compileDir,
+            this.Settings.clsi.docker.image,
+            60000,
+            {}
+          )
+        })
+
+        it('should return the parsed output', function () {
+          expect(this.result).to.deep.equal(this.records)
+        })
       })
       })
 
 
       describe('with a custom imageName', function () {
       describe('with a custom imageName', function () {
         const customImageName = 'foo/bar:tag-0'
         const customImageName = 'foo/bar:tag-0'
-        beforeEach(function () {
-          this.CommandRunner.run.reset()
-          this.CompileManager.syncFromCode(
+        beforeEach(async function () {
+          await this.CompileManager.promises.syncFromCode(
             this.projectId,
             this.projectId,
             this.userId,
             this.userId,
             this.filename,
             this.filename,
             this.line,
             this.line,
             this.column,
             this.column,
-            customImageName,
-            this.callback
+            customImageName
           )
           )
         })
         })
 
 
         it('should execute the synctex binary in a custom docker image', function () {
         it('should execute the synctex binary in a custom docker image', function () {
           const outputFilePath = `${this.compileDir}/output.pdf`
           const outputFilePath = `${this.compileDir}/output.pdf`
           const inputFilePath = `${this.compileDir}/${this.filename}`
           const inputFilePath = `${this.compileDir}/${this.filename}`
-          this.CommandRunner.run.should.have.been.calledWith(
+          expect(this.CommandRunner.promises.run).to.have.been.calledWith(
             `${this.projectId}-${this.userId}`,
             `${this.projectId}-${this.userId}`,
             [
             [
               'synctex',
               'synctex',
@@ -434,7 +434,7 @@ describe('CompileManager', function () {
 
 
     describe('syncFromPdf', function () {
     describe('syncFromPdf', function () {
       beforeEach(function () {
       beforeEach(function () {
-        this.fs.stat.yields(null, {
+        this.fsPromises.stat.resolves({
           isFile() {
           isFile() {
             return true
             return true
           },
           },
@@ -443,93 +443,89 @@ describe('CompileManager', function () {
         this.SynctexOutputParser.parseEditOutput
         this.SynctexOutputParser.parseEditOutput
           .withArgs(this.commandOutput, this.compileDir)
           .withArgs(this.commandOutput, this.compileDir)
           .returns(this.records)
           .returns(this.records)
-        this.CompileManager.syncFromPdf(
-          this.projectId,
-          this.userId,
-          this.page,
-          this.h,
-          this.v,
-          '',
-          this.callback
-        )
       })
       })
 
 
-      it('should execute the synctex binary', function () {
-        const outputFilePath = `${this.compileDir}/output.pdf`
-        this.CommandRunner.run.should.have.been.calledWith(
-          `${this.projectId}-${this.userId}`,
-          [
-            'synctex',
-            'edit',
-            '-o',
-            `${this.page}:${this.h}:${this.v}:${outputFilePath}`,
-          ],
-          this.compileDir,
-          this.Settings.clsi.docker.image,
-          60000,
-          {}
-        )
-      })
+      describe('normal case', function () {
+        beforeEach(async function () {
+          this.result = await this.CompileManager.promises.syncFromPdf(
+            this.projectId,
+            this.userId,
+            this.page,
+            this.h,
+            this.v,
+            ''
+          )
+        })
 
 
-      it('should call the callback with the parsed output', function () {
-        this.callback.should.have.been.calledWith(
-          null,
-          sinon.match.array.deepEquals(this.records)
-        )
+        it('should execute the synctex binary', function () {
+          const outputFilePath = `${this.compileDir}/output.pdf`
+          expect(this.CommandRunner.promises.run).to.have.been.calledWith(
+            `${this.projectId}-${this.userId}`,
+            [
+              'synctex',
+              'edit',
+              '-o',
+              `${this.page}:${this.h}:${this.v}:${outputFilePath}`,
+            ],
+            this.compileDir,
+            this.Settings.clsi.docker.image,
+            60000,
+            {}
+          )
+        })
+
+        it('should return the parsed output', function () {
+          expect(this.result).to.deep.equal(this.records)
+        })
       })
       })
 
 
       describe('with a custom imageName', function () {
       describe('with a custom imageName', function () {
         const customImageName = 'foo/bar:tag-1'
         const customImageName = 'foo/bar:tag-1'
-        beforeEach(function () {
-          this.CommandRunner.run.reset()
-          this.CompileManager.syncFromPdf(
+        beforeEach(async function () {
+          await this.CompileManager.promises.syncFromPdf(
             this.projectId,
             this.projectId,
             this.userId,
             this.userId,
             this.page,
             this.page,
             this.h,
             this.h,
             this.v,
             this.v,
-            customImageName,
-            this.callback
+            customImageName
           )
           )
         })
         })
 
 
         it('should execute the synctex binary in a custom docker image', function () {
         it('should execute the synctex binary in a custom docker image', function () {
           const outputFilePath = `${this.compileDir}/output.pdf`
           const outputFilePath = `${this.compileDir}/output.pdf`
-          this.CommandRunner.run
-            .calledWith(
-              `${this.projectId}-${this.userId}`,
-              [
-                'synctex',
-                'edit',
-                '-o',
-                `${this.page}:${this.h}:${this.v}:${outputFilePath}`,
-              ],
-              this.compileDir,
-              customImageName,
-              60000,
-              {}
-            )
-            .should.equal(true)
+          expect(this.CommandRunner.promises.run).to.have.been.calledWith(
+            `${this.projectId}-${this.userId}`,
+            [
+              'synctex',
+              'edit',
+              '-o',
+              `${this.page}:${this.h}:${this.v}:${outputFilePath}`,
+            ],
+            this.compileDir,
+            customImageName,
+            60000,
+            {}
+          )
         })
         })
       })
       })
     })
     })
   })
   })
 
 
   describe('wordcount', function () {
   describe('wordcount', function () {
-    beforeEach(function () {
+    beforeEach(async function () {
       this.stdout = 'Encoding: ascii\nWords in text: 2'
       this.stdout = 'Encoding: ascii\nWords in text: 2'
-      this.fs.readFile.yields(null, this.stdout)
+      this.fsPromises.readFile.resolves(this.stdout)
 
 
       this.timeout = 60 * 1000
       this.timeout = 60 * 1000
       this.filename = 'main.tex'
       this.filename = 'main.tex'
       this.image = 'example.com/image'
       this.image = 'example.com/image'
 
 
-      this.CompileManager.wordcount(
+      this.result = await this.CompileManager.promises.wordcount(
         this.projectId,
         this.projectId,
         this.userId,
         this.userId,
         this.filename,
         this.filename,
-        this.image,
-        this.callback
+        this.image
       )
       )
     })
     })
 
 
@@ -543,33 +539,29 @@ describe('CompileManager', function () {
         `-out=${this.filePath}.wc`,
         `-out=${this.filePath}.wc`,
       ]
       ]
 
 
-      this.CommandRunner.run
-        .calledWith(
-          `${this.projectId}-${this.userId}`,
-          this.command,
-          this.compileDir,
-          this.image,
-          this.timeout,
-          {}
-        )
-        .should.equal(true)
+      expect(this.CommandRunner.promises.run).to.have.been.calledWith(
+        `${this.projectId}-${this.userId}`,
+        this.command,
+        this.compileDir,
+        this.image,
+        this.timeout,
+        {}
+      )
     })
     })
 
 
-    it('should call the callback with the parsed output', function () {
-      this.callback
-        .calledWith(null, {
-          encode: 'ascii',
-          textWords: 2,
-          headWords: 0,
-          outside: 0,
-          headers: 0,
-          elements: 0,
-          mathInline: 0,
-          mathDisplay: 0,
-          errors: 0,
-          messages: '',
-        })
-        .should.equal(true)
+    it('should return the parsed output', function () {
+      expect(this.result).to.deep.equal({
+        encode: 'ascii',
+        textWords: 2,
+        headWords: 0,
+        outside: 0,
+        headers: 0,
+        elements: 0,
+        mathInline: 0,
+        mathDisplay: 0,
+        errors: 0,
+        messages: '',
+      })
     })
     })
   })
   })
 })
 })

+ 54 - 71
services/clsi/test/unit/js/LockManagerTests.js

@@ -1,88 +1,71 @@
-/* eslint-disable
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const SandboxedModule = require('sandboxed-module')
+const { expect } = require('chai')
 const sinon = require('sinon')
 const sinon = require('sinon')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../app/js/LockManager'
-)
-const Path = require('path')
+const mockFs = require('mock-fs')
+const OError = require('@overleaf/o-error')
+const LockManager = require('../../../app/js/LockManager')
 const Errors = require('../../../app/js/Errors')
 const Errors = require('../../../app/js/Errors')
 
 
-describe('DockerLockManager', function () {
+describe('LockManager', function () {
   beforeEach(function () {
   beforeEach(function () {
-    this.LockManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': {},
-        fs: {
-          lstat: sinon.stub().callsArgWith(1),
-          readdir: sinon.stub().callsArgWith(1),
-        },
-        lockfile: (this.Lockfile = {}),
-      },
+    this.lockFile = '/local/compile/directory/.project-lock'
+    mockFs({
+      '/local/compile/directory': {},
     })
     })
-    return (this.lockFile = '/local/compile/directory/.project-lock')
+    this.clock = sinon.useFakeTimers()
   })
   })
 
 
-  return describe('runWithLock', function () {
-    beforeEach(function () {
-      this.runner = sinon.stub().callsArgWith(0, null, 'foo', 'bar')
-      return (this.callback = sinon.stub())
-    })
+  afterEach(function () {
+    mockFs.restore()
+    this.clock.restore()
+  })
 
 
-    describe('normally', function () {
-      beforeEach(function () {
-        this.Lockfile.lock = sinon.stub().callsArgWith(2, null)
-        this.Lockfile.unlock = sinon.stub().callsArgWith(1, null)
-        return this.LockManager.runWithLock(
-          this.lockFile,
-          this.runner,
-          this.callback
-        )
-      })
+  describe('when the lock is available', function () {
+    it('the lock can be acquired', async function () {
+      await LockManager.acquire(this.lockFile)
+    })
 
 
-      it('should run the compile', function () {
-        return this.runner.calledWith().should.equal(true)
-      })
+    it('acquiring a lock in a nonexistent directory throws an error with debug info', async function () {
+      const err = await expect(
+        LockManager.acquire('/invalid/path/.project-lock')
+      ).to.be.rejected
+      const info = OError.getFullInfo(err)
+      expect(info).to.have.keys(['statLock', 'statDir', 'readdirDir'])
+      expect(info.statLock.code).to.equal('ENOENT')
+      expect(info.statDir.code).to.equal('ENOENT')
+      expect(info.readdirDir.code).to.equal('ENOENT')
+    })
+  })
 
 
-      return it('should call the callback with the response from the compile', function () {
-        return this.callback
-          .calledWithExactly(null, 'foo', 'bar')
-          .should.equal(true)
-      })
+  describe('after the lock is acquired', function () {
+    beforeEach(async function () {
+      this.lock = await LockManager.acquire(this.lockFile)
     })
     })
 
 
-    return describe('when the project is locked', function () {
-      beforeEach(function () {
-        this.error = new Error()
-        this.error.code = 'EEXIST'
-        this.Lockfile.lock = sinon.stub().callsArgWith(2, this.error)
-        this.Lockfile.unlock = sinon.stub().callsArgWith(1, null)
-        return this.LockManager.runWithLock(
-          this.lockFile,
-          this.runner,
-          this.callback
-        )
-      })
+    it("the lock can't be acquired again", function (done) {
+      const promise = LockManager.acquire(this.lockFile)
+      // runAllAsync() will advance through time until there are no pending
+      // timers or promises. It interferes with Mocha's promise interface, so
+      // we use Mocha's callback interface for this test.
+      this.clock.runAllAsync()
+      expect(promise)
+        .to.be.rejectedWith(Errors.AlreadyCompilingError)
+        .then(() => {
+          done()
+        })
+        .catch(err => {
+          done(err)
+        })
+    })
 
 
-      it('should not run the compile', function () {
-        return this.runner.called.should.equal(false)
-      })
+    it('the lock can be acquired again after an expiry period', async function () {
+      // The expiry time is 5 minutes. Let's wait 10 minutes.
+      this.clock.tick(10 * 60 * 1000)
+      await LockManager.acquire(this.lockFile)
+    })
 
 
-      it('should return an error', function () {
-        this.callback
-          .calledWithExactly(sinon.match(Errors.AlreadyCompilingError))
-          .should.equal(true)
-      })
+    it('the lock can be acquired again after it was released', async function () {
+      this.lock.release()
+      await LockManager.acquire(this.lockFile)
     })
     })
   })
   })
 })
 })