CompileManager.coffee 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  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 = require "./CommandRunner"
  19. getCompileName = (project_id, user_id) ->
  20. if user_id? then "#{project_id}-#{user_id}" else project_id
  21. getCompileDir = (project_id, user_id) ->
  22. Path.join(Settings.path.compilesDir, getCompileName(project_id, user_id))
  23. module.exports = CompileManager =
  24. doCompileWithLock: (request, callback = (error, outputFiles) ->) ->
  25. compileDir = getCompileDir(request.project_id, request.user_id)
  26. lockFile = Path.join(compileDir, ".project-lock")
  27. # use a .project-lock file in the compile directory to prevent
  28. # simultaneous compiles
  29. fse.ensureDir compileDir, (error) ->
  30. return callback(error) if error?
  31. LockManager.runWithLock lockFile, (releaseLock) ->
  32. CompileManager.doCompile(request, releaseLock)
  33. , callback
  34. doCompile: (request, callback = (error, outputFiles) ->) ->
  35. compileDir = getCompileDir(request.project_id, request.user_id)
  36. timer = new Metrics.Timer("write-to-disk")
  37. logger.log project_id: request.project_id, user_id: request.user_id, "syncing resources to disk"
  38. ResourceWriter.syncResourcesToDisk request, compileDir, (error, resourceList) ->
  39. # NOTE: resourceList is insecure, it should only be used to exclude files from the output list
  40. if error? and error instanceof Errors.FilesOutOfSyncError
  41. logger.warn project_id: request.project_id, user_id: request.user_id, "files out of sync, please retry"
  42. return callback(error)
  43. else if error?
  44. logger.err err:error, project_id: request.project_id, user_id: request.user_id, "error writing resources to disk"
  45. return callback(error)
  46. logger.log project_id: request.project_id, user_id: request.user_id, time_taken: Date.now() - timer.start, "written files to disk"
  47. timer.done()
  48. injectDraftModeIfRequired = (callback) ->
  49. if request.draft
  50. DraftModeManager.injectDraftMode Path.join(compileDir, request.rootResourcePath), callback
  51. else
  52. callback()
  53. createTikzFileIfRequired = (callback) ->
  54. TikzManager.checkMainFile compileDir, request.rootResourcePath, resourceList, (error, needsMainFile) ->
  55. return callback(error) if error?
  56. if needsMainFile
  57. TikzManager.injectOutputFile compileDir, request.rootResourcePath, callback
  58. else
  59. callback()
  60. # set up environment variables for chktex
  61. env = {}
  62. # only run chktex on LaTeX files (not knitr .Rtex files or any others)
  63. isLaTeXFile = request.rootResourcePath?.match(/\.tex$/i)
  64. if request.check? and isLaTeXFile
  65. env['CHKTEX_OPTIONS'] = '-nall -e9 -e10 -w15 -w16'
  66. env['CHKTEX_ULIMIT_OPTIONS'] = '-t 5 -v 64000'
  67. if request.check is 'error'
  68. env['CHKTEX_EXIT_ON_ERROR'] = 1
  69. if request.check is 'validate'
  70. env['CHKTEX_VALIDATE'] = 1
  71. # apply a series of file modifications/creations for draft mode and tikz
  72. async.series [injectDraftModeIfRequired, createTikzFileIfRequired], (error) ->
  73. return callback(error) if error?
  74. timer = new Metrics.Timer("run-compile")
  75. # find the image tag to log it as a metric, e.g. 2015.1 (convert . to - for graphite)
  76. tag = request.imageName?.match(/:(.*)/)?[1]?.replace(/\./g,'-') or "default"
  77. tag = "other" if not request.project_id.match(/^[0-9a-f]{24}$/) # exclude smoke test
  78. Metrics.inc("compiles")
  79. Metrics.inc("compiles-with-image.#{tag}")
  80. compileName = getCompileName(request.project_id, request.user_id)
  81. LatexRunner.runLatex compileName, {
  82. directory: compileDir
  83. mainFile: request.rootResourcePath
  84. compiler: request.compiler
  85. timeout: request.timeout
  86. image: request.imageName
  87. flags: request.flags
  88. environment: env
  89. }, (error, output, stats, timings) ->
  90. # request was for validation only
  91. if request.check is "validate"
  92. result = if error?.code then "fail" else "pass"
  93. error = new Error("validation")
  94. error.validate = result
  95. # request was for compile, and failed on validation
  96. if request.check is "error" and error?.message is 'exited'
  97. error = new Error("compilation")
  98. error.validate = "fail"
  99. # compile was killed by user, was a validation, or a compile which failed validation
  100. if error?.terminated or error?.validate or error?.timedout
  101. OutputFileFinder.findOutputFiles resourceList, compileDir, (err, outputFiles) ->
  102. return callback(err) if err?
  103. error.outputFiles = outputFiles # return output files so user can check logs
  104. callback(error)
  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 = "#{base_dir}/output.pdf"
  185. command = ["code", synctex_path, file_path, line, column]
  186. fse.ensureDir compileDir, (error) ->
  187. if error?
  188. logger.err {error, project_id, user_id, file_name}, "error ensuring dir for sync from code"
  189. return callback(error)
  190. CompileManager._runSynctex project_id, user_id, command, (error, stdout) ->
  191. return callback(error) if error?
  192. 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"
  193. callback null, CompileManager._parseSynctexFromCodeOutput(stdout)
  194. syncFromPdf: (project_id, user_id, page, h, v, callback = (error, filePositions) ->) ->
  195. compileName = getCompileName(project_id, user_id)
  196. compileDir = getCompileDir(project_id, user_id)
  197. base_dir = Settings.path.synctexBaseDir(compileName)
  198. synctex_path = "#{base_dir}/output.pdf"
  199. command = ["pdf", synctex_path, page, h, v]
  200. fse.ensureDir compileDir, (error) ->
  201. if error?
  202. logger.err {error, project_id, user_id, file_name}, "error ensuring dir for sync to code"
  203. return callback(error)
  204. CompileManager._runSynctex project_id, user_id, command, (error, stdout) ->
  205. return callback(error) if error?
  206. logger.log project_id: project_id, user_id:user_id, page: page, h: h, v:v, stdout: stdout, "synctex pdf output"
  207. callback null, CompileManager._parseSynctexFromPdfOutput(stdout, base_dir)
  208. _checkFileExists: (path, callback = (error) ->) ->
  209. synctexDir = Path.dirname(path)
  210. synctexFile = Path.join(synctexDir, "output.synctex.gz")
  211. fs.stat synctexDir, (error, stats) ->
  212. if error?.code is 'ENOENT'
  213. return callback(new Errors.NotFoundError("called synctex with no output directory"))
  214. return callback(error) if error?
  215. fs.stat synctexFile, (error, stats) ->
  216. if error?.code is 'ENOENT'
  217. return callback(new Errors.NotFoundError("called synctex with no output file"))
  218. return callback(error) if error?
  219. return callback(new Error("not a file")) if not stats?.isFile()
  220. callback()
  221. _runSynctex: (project_id, user_id, command, callback = (error, stdout) ->) ->
  222. seconds = 1000
  223. command.unshift("/opt/synctex")
  224. directory = getCompileDir(project_id, user_id)
  225. timeout = 60 * 1000 # increased to allow for large projects
  226. compileName = getCompileName(project_id, user_id)
  227. CommandRunner.run compileName, command, directory, Settings.clsi?.docker.image, timeout, {}, (error, output) ->
  228. if error?
  229. logger.err err:error, command:command, project_id:project_id, user_id:user_id, "error running synctex"
  230. return callback(error)
  231. callback(null, output.stdout)
  232. _parseSynctexFromCodeOutput: (output) ->
  233. results = []
  234. for line in output.split("\n")
  235. [node, page, h, v, width, height] = line.split("\t")
  236. if node == "NODE"
  237. results.push {
  238. page: parseInt(page, 10)
  239. h: parseFloat(h)
  240. v: parseFloat(v)
  241. height: parseFloat(height)
  242. width: parseFloat(width)
  243. }
  244. return results
  245. _parseSynctexFromPdfOutput: (output, base_dir) ->
  246. results = []
  247. for line in output.split("\n")
  248. [node, file_path, line, column] = line.split("\t")
  249. if node == "NODE"
  250. file = file_path.slice(base_dir.length + 1)
  251. results.push {
  252. file: file
  253. line: parseInt(line, 10)
  254. column: parseInt(column, 10)
  255. }
  256. return results
  257. wordcount: (project_id, user_id, file_name, image, callback = (error, pdfPositions) ->) ->
  258. logger.log project_id:project_id, user_id:user_id, file_name:file_name, image:image, "running wordcount"
  259. file_path = "$COMPILE_DIR/" + file_name
  260. command = [ "texcount", '-nocol', '-inc', file_path, "-out=" + file_path + ".wc"]
  261. compileDir = getCompileDir(project_id, user_id)
  262. timeout = 60 * 1000
  263. compileName = getCompileName(project_id, user_id)
  264. fse.ensureDir compileDir, (error) ->
  265. if error?
  266. logger.err {error, project_id, user_id, file_name}, "error ensuring dir for sync from code"
  267. return callback(error)
  268. CommandRunner.run compileName, command, compileDir, image, timeout, {}, (error) ->
  269. return callback(error) if error?
  270. fs.readFile compileDir + "/" + file_name + ".wc", "utf-8", (err, stdout) ->
  271. if err?
  272. #call it node_err so sentry doesn't use random path error as unique id so it can't be ignored
  273. logger.err node_err:err, command:command, compileDir:compileDir, project_id:project_id, user_id:user_id, "error reading word count output"
  274. return callback(err)
  275. results = CompileManager._parseWordcountFromOutput(stdout)
  276. logger.log project_id:project_id, user_id:user_id, wordcount: results, "word count results"
  277. callback null, results
  278. _parseWordcountFromOutput: (output) ->
  279. results = {
  280. encode: ""
  281. textWords: 0
  282. headWords: 0
  283. outside: 0
  284. headers: 0
  285. elements: 0
  286. mathInline: 0
  287. mathDisplay: 0
  288. errors: 0
  289. messages: ""
  290. }
  291. for line in output.split("\n")
  292. [data, info] = line.split(":")
  293. if data.indexOf("Encoding") > -1
  294. results['encode'] = info.trim()
  295. if data.indexOf("in text") > -1
  296. results['textWords'] = parseInt(info, 10)
  297. if data.indexOf("in head") > -1
  298. results['headWords'] = parseInt(info, 10)
  299. if data.indexOf("outside") > -1
  300. results['outside'] = parseInt(info, 10)
  301. if data.indexOf("of head") > -1
  302. results['headers'] = parseInt(info, 10)
  303. if data.indexOf("Number of floats/tables/figures") > -1
  304. results['elements'] = parseInt(info, 10)
  305. if data.indexOf("Number of math inlines") > -1
  306. results['mathInline'] = parseInt(info, 10)
  307. if data.indexOf("Number of math displayed") > -1
  308. results['mathDisplay'] = parseInt(info, 10)
  309. if data is "(errors" # errors reported as (errors:123)
  310. results['errors'] = parseInt(info, 10)
  311. if line.indexOf("!!! ") > -1 # errors logged as !!! message !!!
  312. results['messages'] += line + "\n"
  313. return results