| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345 |
- ResourceWriter = require "./ResourceWriter"
- LatexRunner = require "./LatexRunner"
- OutputFileFinder = require "./OutputFileFinder"
- OutputCacheManager = require "./OutputCacheManager"
- Settings = require("settings-sharelatex")
- Path = require "path"
- logger = require "logger-sharelatex"
- Metrics = require "./Metrics"
- child_process = require "child_process"
- DraftModeManager = require "./DraftModeManager"
- TikzManager = require "./TikzManager"
- LockManager = require "./LockManager"
- fs = require("fs")
- fse = require "fs-extra"
- os = require("os")
- async = require "async"
- Errors = require './Errors'
- CommandRunner = require "./CommandRunner"
- getCompileName = (project_id, user_id) ->
- if user_id? then "#{project_id}-#{user_id}" else project_id
- getCompileDir = (project_id, user_id) ->
- Path.join(Settings.path.compilesDir, getCompileName(project_id, user_id))
- module.exports = CompileManager =
- doCompileWithLock: (request, callback = (error, outputFiles) ->) ->
- compileDir = getCompileDir(request.project_id, request.user_id)
- lockFile = Path.join(compileDir, ".project-lock")
- # use a .project-lock file in the compile directory to prevent
- # simultaneous compiles
- fse.ensureDir compileDir, (error) ->
- return callback(error) if error?
- LockManager.runWithLock lockFile, (releaseLock) ->
- CompileManager.doCompile(request, releaseLock)
- , callback
- doCompile: (request, callback = (error, outputFiles) ->) ->
- compileDir = getCompileDir(request.project_id, request.user_id)
- timer = new Metrics.Timer("write-to-disk")
- logger.log project_id: request.project_id, user_id: request.user_id, "syncing resources to disk"
- ResourceWriter.syncResourcesToDisk request, compileDir, (error, resourceList) ->
- # NOTE: resourceList is insecure, it should only be used to exclude files from the output list
- if error? and error instanceof Errors.FilesOutOfSyncError
- logger.warn project_id: request.project_id, user_id: request.user_id, "files out of sync, please retry"
- return callback(error)
- else if error?
- logger.err err:error, project_id: request.project_id, user_id: request.user_id, "error writing resources to disk"
- return callback(error)
- logger.log project_id: request.project_id, user_id: request.user_id, time_taken: Date.now() - timer.start, "written files to disk"
- timer.done()
- injectDraftModeIfRequired = (callback) ->
- if request.draft
- DraftModeManager.injectDraftMode Path.join(compileDir, request.rootResourcePath), callback
- else
- callback()
- createTikzFileIfRequired = (callback) ->
- TikzManager.checkMainFile compileDir, request.rootResourcePath, resourceList, (error, needsMainFile) ->
- return callback(error) if error?
- if needsMainFile
- TikzManager.injectOutputFile compileDir, request.rootResourcePath, callback
- else
- callback()
- # set up environment variables for chktex
- env = {}
- # only run chktex on LaTeX files (not knitr .Rtex files or any others)
- isLaTeXFile = request.rootResourcePath?.match(/\.tex$/i)
- if request.check? and isLaTeXFile
- env['CHKTEX_OPTIONS'] = '-nall -e9 -e10 -w15 -w16'
- env['CHKTEX_ULIMIT_OPTIONS'] = '-t 5 -v 64000'
- if request.check is 'error'
- env['CHKTEX_EXIT_ON_ERROR'] = 1
- if request.check is 'validate'
- env['CHKTEX_VALIDATE'] = 1
- # apply a series of file modifications/creations for draft mode and tikz
- async.series [injectDraftModeIfRequired, createTikzFileIfRequired], (error) ->
- return callback(error) if error?
- timer = new Metrics.Timer("run-compile")
- # find the image tag to log it as a metric, e.g. 2015.1 (convert . to - for graphite)
- tag = request.imageName?.match(/:(.*)/)?[1]?.replace(/\./g,'-') or "default"
- tag = "other" if not request.project_id.match(/^[0-9a-f]{24}$/) # exclude smoke test
- Metrics.inc("compiles")
- Metrics.inc("compiles-with-image.#{tag}")
- 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
- }, (error, output, stats, timings) ->
- # request was for validation only
- if request.check is "validate"
- result = if error?.code then "fail" else "pass"
- error = new Error("validation")
- error.validate = result
- # request was for compile, and failed on validation
- if request.check is "error" and error?.message is '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 or error?.validate or error?.timedout
- OutputFileFinder.findOutputFiles resourceList, compileDir, (err, outputFiles) ->
- return callback(err) if err?
- error.outputFiles = outputFiles # return output files so user can check logs
- callback(error)
- return
- # compile completed normally
- return callback(error) if error?
- Metrics.inc("compiles-succeeded")
- for metric_key, metric_value of stats or {}
- Metrics.count(metric_key, metric_value)
- for metric_key, metric_value of timings or {}
- Metrics.timing(metric_key, metric_value)
- loadavg = os.loadavg?()
- Metrics.gauge("load-avg", loadavg[0]) if loadavg?
- ts = timer.done()
- logger.log {project_id: request.project_id, user_id: request.user_id, time_taken: ts, stats:stats, timings:timings, loadavg:loadavg}, "done compile"
- if stats?["latex-runs"] > 0
- Metrics.timing("run-compile-per-pass", ts / stats["latex-runs"])
- if stats?["latex-runs"] > 0 and timings?["cpu-time"] > 0
- Metrics.timing("run-compile-cpu-time-per-pass", timings["cpu-time"] / stats["latex-runs"])
- OutputFileFinder.findOutputFiles resourceList, compileDir, (error, outputFiles) ->
- return callback(error) if error?
- OutputCacheManager.saveOutputFiles outputFiles, compileDir, (error, newOutputFiles) ->
- callback null, newOutputFiles
- stopCompile: (project_id, user_id, callback = (error) ->) ->
- compileName = getCompileName(project_id, user_id)
- LatexRunner.killLatex compileName, callback
- clearProject: (project_id, user_id, _callback = (error) ->) ->
- callback = (error) ->
- _callback(error)
- _callback = () ->
- compileDir = getCompileDir(project_id, user_id)
- CompileManager._checkDirectory compileDir, (err, exists) ->
- return callback(err) if err?
- return callback() if not exists # skip removal if no directory present
- proc = child_process.spawn "rm", ["-r", compileDir]
- proc.on "error", callback
- stderr = ""
- proc.stderr.on "data", (chunk) -> stderr += chunk.toString()
- proc.on "close", (code) ->
- if code == 0
- return callback(null)
- else
- return callback(new Error("rm -r #{compileDir} failed: #{stderr}"))
- _findAllDirs: (callback = (error, allDirs) ->) ->
- root = Settings.path.compilesDir
- fs.readdir root, (err, files) ->
- return callback(err) if err?
- allDirs = (Path.join(root, file) for file in files)
- callback(null, allDirs)
- clearExpiredProjects: (max_cache_age_ms, callback = (error) ->) ->
- now = Date.now()
- # action for each directory
- expireIfNeeded = (checkDir, cb) ->
- fs.stat checkDir, (err, stats) ->
- return cb() if err? # ignore errors checking directory
- age = now - stats.mtime
- hasExpired = (age > max_cache_age_ms)
- if hasExpired then fse.remove(checkDir, cb) else cb()
- # iterate over all project directories
- CompileManager._findAllDirs (error, allDirs) ->
- return callback() if error?
- async.eachSeries allDirs, expireIfNeeded, callback
- _checkDirectory: (compileDir, callback = (error, exists) ->) ->
- fs.lstat compileDir, (err, stats) ->
- if err?.code is 'ENOENT'
- return callback(null, false) # directory does not exist
- else if err?
- logger.err {dir: compileDir, err:err}, "error on stat of project directory for removal"
- return callback(err)
- else if not stats?.isDirectory()
- logger.err {dir: compileDir, stats:stats}, "bad project directory for removal"
- return callback new Error("project directory is not directory")
- else
- callback(null, true) # directory exists
- syncFromCode: (project_id, user_id, file_name, line, column, callback = (error, pdfPositions) ->) ->
- # 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
- # wherever it is on the host.
- compileName = getCompileName(project_id, user_id)
- base_dir = Settings.path.synctexBaseDir(compileName)
- file_path = base_dir + "/" + file_name
- compileDir = getCompileDir(project_id, user_id)
- synctex_path = "#{base_dir}/output.pdf"
- command = ["code", synctex_path, file_path, line, column]
- fse.ensureDir compileDir, (error) ->
- if error?
- logger.err {error, project_id, user_id, file_name}, "error ensuring dir for sync from code"
- return callback(error)
- CompileManager._runSynctex project_id, user_id, command, (error, stdout) ->
- return callback(error) if error?
- logger.log project_id: project_id, user_id:user_id, file_name: file_name, line: line, column: column, command:command, stdout: stdout, "synctex code output"
- callback null, CompileManager._parseSynctexFromCodeOutput(stdout)
- syncFromPdf: (project_id, user_id, page, h, v, callback = (error, filePositions) ->) ->
- compileName = getCompileName(project_id, user_id)
- compileDir = getCompileDir(project_id, user_id)
- base_dir = Settings.path.synctexBaseDir(compileName)
- synctex_path = "#{base_dir}/output.pdf"
- command = ["pdf", synctex_path, page, h, v]
- fse.ensureDir compileDir, (error) ->
- if error?
- logger.err {error, project_id, user_id, file_name}, "error ensuring dir for sync to code"
- return callback(error)
- CompileManager._runSynctex project_id, user_id, command, (error, stdout) ->
- return callback(error) if error?
- logger.log project_id: project_id, user_id:user_id, page: page, h: h, v:v, stdout: stdout, "synctex pdf output"
- callback null, CompileManager._parseSynctexFromPdfOutput(stdout, base_dir)
- _checkFileExists: (path, callback = (error) ->) ->
- synctexDir = Path.dirname(path)
- synctexFile = Path.join(synctexDir, "output.synctex.gz")
- fs.stat synctexDir, (error, stats) ->
- if error?.code is 'ENOENT'
- return callback(new Errors.NotFoundError("called synctex with no output directory"))
- return callback(error) if error?
- fs.stat synctexFile, (error, stats) ->
- if error?.code is 'ENOENT'
- return callback(new Errors.NotFoundError("called synctex with no output file"))
- return callback(error) if error?
- return callback(new Error("not a file")) if not stats?.isFile()
- callback()
- _runSynctex: (project_id, user_id, command, callback = (error, stdout) ->) ->
- seconds = 1000
- command.unshift("/opt/synctex")
- directory = getCompileDir(project_id, user_id)
- timeout = 60 * 1000 # increased to allow for large projects
- compileName = getCompileName(project_id, user_id)
- CommandRunner.run compileName, command, directory, Settings.clsi?.docker.image, timeout, {}, (error, output) ->
- if error?
- logger.err err:error, command:command, project_id:project_id, user_id:user_id, "error running synctex"
- return callback(error)
- callback(null, output.stdout)
- _parseSynctexFromCodeOutput: (output) ->
- results = []
- for line in output.split("\n")
- [node, page, h, v, width, height] = line.split("\t")
- if node == "NODE"
- results.push {
- page: parseInt(page, 10)
- h: parseFloat(h)
- v: parseFloat(v)
- height: parseFloat(height)
- width: parseFloat(width)
- }
- return results
- _parseSynctexFromPdfOutput: (output, base_dir) ->
- results = []
- for line in output.split("\n")
- [node, file_path, line, column] = line.split("\t")
- if node == "NODE"
- file = file_path.slice(base_dir.length + 1)
- results.push {
- file: file
- line: parseInt(line, 10)
- column: parseInt(column, 10)
- }
- return results
- wordcount: (project_id, user_id, file_name, image, callback = (error, pdfPositions) ->) ->
- logger.log project_id:project_id, user_id:user_id, file_name:file_name, image:image, "running wordcount"
- file_path = "$COMPILE_DIR/" + file_name
- command = [ "texcount", '-nocol', '-inc', file_path, "-out=" + file_path + ".wc"]
- compileDir = getCompileDir(project_id, user_id)
- timeout = 60 * 1000
- compileName = getCompileName(project_id, user_id)
- fse.ensureDir compileDir, (error) ->
- if error?
- logger.err {error, project_id, user_id, file_name}, "error ensuring dir for sync from code"
- return callback(error)
- CommandRunner.run compileName, command, compileDir, image, timeout, {}, (error) ->
- return callback(error) if error?
- fs.readFile compileDir + "/" + file_name + ".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:command, compileDir:compileDir, project_id:project_id, user_id:user_id, "error reading word count output"
- return callback(err)
- results = CompileManager._parseWordcountFromOutput(stdout)
- logger.log project_id:project_id, user_id:user_id, wordcount: results, "word count results"
- callback null, results
- _parseWordcountFromOutput: (output) ->
- results = {
- encode: ""
- textWords: 0
- headWords: 0
- outside: 0
- headers: 0
- elements: 0
- mathInline: 0
- mathDisplay: 0
- errors: 0
- messages: ""
- }
- for line in output.split("\n")
- [data, info] = line.split(":")
- if data.indexOf("Encoding") > -1
- results['encode'] = info.trim()
- if data.indexOf("in text") > -1
- results['textWords'] = parseInt(info, 10)
- if data.indexOf("in head") > -1
- results['headWords'] = parseInt(info, 10)
- if data.indexOf("outside") > -1
- results['outside'] = parseInt(info, 10)
- if data.indexOf("of head") > -1
- results['headers'] = parseInt(info, 10)
- if data.indexOf("Number of floats/tables/figures") > -1
- results['elements'] = parseInt(info, 10)
- if data.indexOf("Number of math inlines") > -1
- results['mathInline'] = parseInt(info, 10)
- if data.indexOf("Number of math displayed") > -1
- results['mathDisplay'] = parseInt(info, 10)
- if data is "(errors" # errors reported as (errors:123)
- results['errors'] = parseInt(info, 10)
- if line.indexOf("!!! ") > -1 # errors logged as !!! message !!!
- results['messages'] += line + "\n"
- return results
|