CompileManager.coffee 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. ResourceWriter = require "./ResourceWriter"
  2. LatexRunner = require "./LatexRunner"
  3. OutputFileFinder = require "./OutputFileFinder"
  4. OutputCacheManager = require "./OutputCacheManager"
  5. Settings = require("settings-sharelatex")
  6. Path = require "path"
  7. logger = require "logger-sharelatex"
  8. Metrics = require "./Metrics"
  9. child_process = require "child_process"
  10. DraftModeManager = require "./DraftModeManager"
  11. TikzManager = require "./TikzManager"
  12. LockManager = require "./LockManager"
  13. fs = require("fs")
  14. fse = require "fs-extra"
  15. os = require("os")
  16. async = require "async"
  17. Errors = require './Errors'
  18. commandRunner = Settings.clsi?.commandRunner or "./CommandRunner"
  19. logger.info commandRunner:commandRunner, "selecting command runner for clsi"
  20. CommandRunner = require(commandRunner)
  21. getCompileName = (project_id, user_id) ->
  22. if user_id? then "#{project_id}-#{user_id}" else project_id
  23. getCompileDir = (project_id, user_id) ->
  24. Path.join(Settings.path.compilesDir, getCompileName(project_id, user_id))
  25. module.exports = CompileManager =
  26. doCompileWithLock: (request, callback = (error, outputFiles) ->) ->
  27. compileDir = getCompileDir(request.project_id, request.user_id)
  28. lockFile = Path.join(compileDir, ".project-lock")
  29. # use a .project-lock file in the compile directory to prevent
  30. # simultaneous compiles
  31. fse.ensureDir compileDir, (error) ->
  32. return callback(error) if error?
  33. LockManager.runWithLock lockFile, (releaseLock) ->
  34. CompileManager.doCompile(request, releaseLock)
  35. , callback
  36. doCompile: (request, callback = (error, outputFiles) ->) ->
  37. compileDir = getCompileDir(request.project_id, request.user_id)
  38. timer = new Metrics.Timer("write-to-disk")
  39. logger.log project_id: request.project_id, user_id: request.user_id, "syncing resources to disk"
  40. ResourceWriter.syncResourcesToDisk request, compileDir, (error, resourceList) ->
  41. # NOTE: resourceList is insecure, it should only be used to exclude files from the output list
  42. if error? and error instanceof Errors.FilesOutOfSyncError
  43. logger.warn project_id: request.project_id, user_id: request.user_id, "files out of sync, please retry"
  44. return callback(error)
  45. else if error?
  46. logger.err err:error, project_id: request.project_id, user_id: request.user_id, "error writing resources to disk"
  47. return callback(error)
  48. logger.log project_id: request.project_id, user_id: request.user_id, time_taken: Date.now() - timer.start, "written files to disk"
  49. timer.done()
  50. injectDraftModeIfRequired = (callback) ->
  51. if request.draft
  52. DraftModeManager.injectDraftMode Path.join(compileDir, request.rootResourcePath), callback
  53. else
  54. callback()
  55. createTikzFileIfRequired = (callback) ->
  56. TikzManager.checkMainFile compileDir, request.rootResourcePath, resourceList, (error, usesTikzExternalize) ->
  57. return callback(error) if error?
  58. if usesTikzExternalize
  59. TikzManager.injectOutputFile compileDir, request.rootResourcePath, callback
  60. else
  61. callback()
  62. # set up environment variables for chktex
  63. env = {}
  64. # only run chktex on LaTeX files (not knitr .Rtex files or any others)
  65. isLaTeXFile = request.rootResourcePath?.match(/\.tex$/i)
  66. if request.check? and isLaTeXFile
  67. env['CHKTEX_OPTIONS'] = '-nall -e9 -e10 -w15 -w16'
  68. env['CHKTEX_ULIMIT_OPTIONS'] = '-t 5 -v 64000'
  69. if request.check is 'error'
  70. env['CHKTEX_EXIT_ON_ERROR'] = 1
  71. if request.check is 'validate'
  72. env['CHKTEX_VALIDATE'] = 1
  73. # apply a series of file modifications/creations for draft mode and tikz
  74. async.series [injectDraftModeIfRequired, createTikzFileIfRequired], (error) ->
  75. return callback(error) if error?
  76. timer = new Metrics.Timer("run-compile")
  77. # find the image tag to log it as a metric, e.g. 2015.1 (convert . to - for graphite)
  78. tag = request.imageName?.match(/:(.*)/)?[1]?.replace(/\./g,'-') or "default"
  79. tag = "other" if not request.project_id.match(/^[0-9a-f]{24}$/) # exclude smoke test
  80. Metrics.inc("compiles")
  81. Metrics.inc("compiles-with-image.#{tag}")
  82. compileName = getCompileName(request.project_id, request.user_id)
  83. LatexRunner.runLatex compileName, {
  84. directory: compileDir
  85. mainFile: request.rootResourcePath
  86. compiler: request.compiler
  87. timeout: request.timeout
  88. image: request.imageName
  89. environment: env
  90. }, (error, output, stats, timings) ->
  91. # request was for validation only
  92. if request.check is "validate"
  93. result = if error?.code then "fail" else "pass"
  94. error = new Error("validation")
  95. error.validate = result
  96. # request was for compile, and failed on validation
  97. if request.check is "error" and error?.message is 'exited'
  98. error = new Error("compilation")
  99. error.validate = "fail"
  100. # compile was killed by user, was a validation, or a compile which failed validation
  101. if error?.terminated or error?.validate
  102. OutputFileFinder.findOutputFiles resourceList, compileDir, (err, outputFiles) ->
  103. return callback(err) if err?
  104. callback(error, outputFiles) # return output files so user can check logs
  105. return
  106. # compile completed normally
  107. return callback(error) if error?
  108. Metrics.inc("compiles-succeeded")
  109. for metric_key, metric_value of stats or {}
  110. Metrics.count(metric_key, metric_value)
  111. for metric_key, metric_value of timings or {}
  112. Metrics.timing(metric_key, metric_value)
  113. loadavg = os.loadavg?()
  114. Metrics.gauge("load-avg", loadavg[0]) if loadavg?
  115. ts = timer.done()
  116. logger.log {project_id: request.project_id, user_id: request.user_id, time_taken: ts, stats:stats, timings:timings, loadavg:loadavg}, "done compile"
  117. if stats?["latex-runs"] > 0
  118. Metrics.timing("run-compile-per-pass", ts / stats["latex-runs"])
  119. if stats?["latex-runs"] > 0 and timings?["cpu-time"] > 0
  120. Metrics.timing("run-compile-cpu-time-per-pass", timings["cpu-time"] / stats["latex-runs"])
  121. OutputFileFinder.findOutputFiles resourceList, compileDir, (error, outputFiles) ->
  122. return callback(error) if error?
  123. OutputCacheManager.saveOutputFiles outputFiles, compileDir, (error, newOutputFiles) ->
  124. callback null, newOutputFiles
  125. stopCompile: (project_id, user_id, callback = (error) ->) ->
  126. compileName = getCompileName(project_id, user_id)
  127. LatexRunner.killLatex compileName, callback
  128. clearProject: (project_id, user_id, _callback = (error) ->) ->
  129. callback = (error) ->
  130. _callback(error)
  131. _callback = () ->
  132. compileDir = getCompileDir(project_id, user_id)
  133. CompileManager._checkDirectory compileDir, (err, exists) ->
  134. return callback(err) if err?
  135. return callback() if not exists # skip removal if no directory present
  136. proc = child_process.spawn "rm", ["-r", compileDir]
  137. proc.on "error", callback
  138. stderr = ""
  139. proc.stderr.on "data", (chunk) -> stderr += chunk.toString()
  140. proc.on "close", (code) ->
  141. if code == 0
  142. return callback(null)
  143. else
  144. return callback(new Error("rm -r #{compileDir} failed: #{stderr}"))
  145. _findAllDirs: (callback = (error, allDirs) ->) ->
  146. root = Settings.path.compilesDir
  147. fs.readdir root, (err, files) ->
  148. return callback(err) if err?
  149. allDirs = (Path.join(root, file) for file in files)
  150. callback(null, allDirs)
  151. clearExpiredProjects: (max_cache_age_ms, callback = (error) ->) ->
  152. now = Date.now()
  153. # action for each directory
  154. expireIfNeeded = (checkDir, cb) ->
  155. fs.stat checkDir, (err, stats) ->
  156. return cb() if err? # ignore errors checking directory
  157. age = now - stats.mtime
  158. hasExpired = (age > max_cache_age_ms)
  159. if hasExpired then fse.remove(checkDir, cb) else cb()
  160. # iterate over all project directories
  161. CompileManager._findAllDirs (error, allDirs) ->
  162. return callback() if error?
  163. async.eachSeries allDirs, expireIfNeeded, callback
  164. _checkDirectory: (compileDir, callback = (error, exists) ->) ->
  165. fs.lstat compileDir, (err, stats) ->
  166. if err?.code is 'ENOENT'
  167. return callback(null, false) # directory does not exist
  168. else if err?
  169. logger.err {dir: compileDir, err:err}, "error on stat of project directory for removal"
  170. return callback(err)
  171. else if not stats?.isDirectory()
  172. logger.err {dir: compileDir, stats:stats}, "bad project directory for removal"
  173. return callback new Error("project directory is not directory")
  174. else
  175. callback(null, true) # directory exists
  176. syncFromCode: (project_id, user_id, file_name, line, column, callback = (error, pdfPositions) ->) ->
  177. # If LaTeX was run in a virtual environment, the file path that synctex expects
  178. # might not match the file path on the host. The .synctex.gz file however, will be accessed
  179. # wherever it is on the host.
  180. compileName = getCompileName(project_id, user_id)
  181. base_dir = Settings.path.synctexBaseDir(compileName)
  182. file_path = base_dir + "/" + file_name
  183. compileDir = getCompileDir(project_id, user_id)
  184. synctex_path = Path.join(compileDir, "output.pdf")
  185. CompileManager._runSynctex ["code", synctex_path, file_path, line, column], (error, stdout) ->
  186. return callback(error) if error?
  187. logger.log project_id: project_id, user_id:user_id, file_name: file_name, line: line, column: column, stdout: stdout, "synctex code output"
  188. callback null, CompileManager._parseSynctexFromCodeOutput(stdout)
  189. syncFromPdf: (project_id, user_id, page, h, v, callback = (error, filePositions) ->) ->
  190. compileName = getCompileName(project_id, user_id)
  191. base_dir = Settings.path.synctexBaseDir(compileName)
  192. compileDir = getCompileDir(project_id, user_id)
  193. synctex_path = Path.join(compileDir, "output.pdf")
  194. CompileManager._runSynctex ["pdf", synctex_path, page, h, v], (error, stdout) ->
  195. return callback(error) if error?
  196. logger.log project_id: project_id, user_id:user_id, page: page, h: h, v:v, stdout: stdout, "synctex pdf output"
  197. callback null, CompileManager._parseSynctexFromPdfOutput(stdout, base_dir)
  198. _checkFileExists: (path, callback = (error) ->) ->
  199. synctexDir = Path.dirname(path)
  200. synctexFile = Path.join(synctexDir, "output.synctex.gz")
  201. fs.stat synctexDir, (error, stats) ->
  202. if error?.code is 'ENOENT'
  203. return callback(new Errors.NotFoundError("called synctex with no output directory"))
  204. return callback(error) if error?
  205. fs.stat synctexFile, (error, stats) ->
  206. if error?.code is 'ENOENT'
  207. return callback(new Errors.NotFoundError("called synctex with no output file"))
  208. return callback(error) if error?
  209. return callback(new Error("not a file")) if not stats?.isFile()
  210. callback()
  211. _runSynctex: (args, callback = (error, stdout) ->) ->
  212. bin_path = Path.resolve(__dirname + "/../../bin/synctex")
  213. seconds = 1000
  214. outputFilePath = args[1]
  215. CompileManager._checkFileExists outputFilePath, (error) ->
  216. return callback(error) if error?
  217. if Settings.clsi?.synctexCommandWrapper?
  218. [bin_path, args] = Settings.clsi?.synctexCommandWrapper bin_path, args
  219. child_process.execFile bin_path, args, timeout: 10 * seconds, (error, stdout, stderr) ->
  220. if error?
  221. logger.err err:error, args:args, "error running synctex"
  222. return callback(error)
  223. callback(null, stdout)
  224. _parseSynctexFromCodeOutput: (output) ->
  225. results = []
  226. for line in output.split("\n")
  227. [node, page, h, v, width, height] = line.split("\t")
  228. if node == "NODE"
  229. results.push {
  230. page: parseInt(page, 10)
  231. h: parseFloat(h)
  232. v: parseFloat(v)
  233. height: parseFloat(height)
  234. width: parseFloat(width)
  235. }
  236. return results
  237. _parseSynctexFromPdfOutput: (output, base_dir) ->
  238. results = []
  239. for line in output.split("\n")
  240. [node, file_path, line, column] = line.split("\t")
  241. if node == "NODE"
  242. file = file_path.slice(base_dir.length + 1)
  243. results.push {
  244. file: file
  245. line: parseInt(line, 10)
  246. column: parseInt(column, 10)
  247. }
  248. return results
  249. wordcount: (project_id, user_id, file_name, image, callback = (error, pdfPositions) ->) ->
  250. logger.log project_id:project_id, user_id:user_id, file_name:file_name, image:image, "running wordcount"
  251. file_path = "$COMPILE_DIR/" + file_name
  252. command = [ "texcount", '-nocol', '-inc', file_path, "-out=" + file_path + ".wc"]
  253. directory = getCompileDir(project_id, user_id)
  254. timeout = 60 * 1000 # increased to allow for large projects
  255. compileName = getCompileName(project_id, user_id)
  256. CommandRunner.run compileName, command, directory, image, timeout, {}, (error) ->
  257. return callback(error) if error?
  258. fs.readFile directory + "/" + file_name + ".wc", "utf-8", (err, stdout) ->
  259. if err?
  260. logger.err err:err, command:command, directory:directory, project_id:project_id, user_id:user_id, "error reading word count output"
  261. return callback(err)
  262. results = CompileManager._parseWordcountFromOutput(stdout)
  263. logger.log project_id:project_id, user_id:user_id, wordcount: results, "word count results"
  264. callback null, results
  265. _parseWordcountFromOutput: (output) ->
  266. results = {
  267. encode: ""
  268. textWords: 0
  269. headWords: 0
  270. outside: 0
  271. headers: 0
  272. elements: 0
  273. mathInline: 0
  274. mathDisplay: 0
  275. errors: 0
  276. messages: ""
  277. }
  278. for line in output.split("\n")
  279. [data, info] = line.split(":")
  280. if data.indexOf("Encoding") > -1
  281. results['encode'] = info.trim()
  282. if data.indexOf("in text") > -1
  283. results['textWords'] = parseInt(info, 10)
  284. if data.indexOf("in head") > -1
  285. results['headWords'] = parseInt(info, 10)
  286. if data.indexOf("outside") > -1
  287. results['outside'] = parseInt(info, 10)
  288. if data.indexOf("of head") > -1
  289. results['headers'] = parseInt(info, 10)
  290. if data.indexOf("Number of floats/tables/figures") > -1
  291. results['elements'] = parseInt(info, 10)
  292. if data.indexOf("Number of math inlines") > -1
  293. results['mathInline'] = parseInt(info, 10)
  294. if data.indexOf("Number of math displayed") > -1
  295. results['mathDisplay'] = parseInt(info, 10)
  296. if data is "(errors" # errors reported as (errors:123)
  297. results['errors'] = parseInt(info, 10)
  298. if line.indexOf("!!! ") > -1 # errors logged as !!! message !!!
  299. results['messages'] += line + "\n"
  300. return results