CompileManager.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  1. /* eslint-disable
  2. camelcase,
  3. handle-callback-err,
  4. no-return-assign,
  5. no-undef,
  6. no-unused-vars,
  7. */
  8. // TODO: This file was created by bulk-decaffeinate.
  9. // Fix any style issues and re-enable lint.
  10. /*
  11. * decaffeinate suggestions:
  12. * DS101: Remove unnecessary use of Array.from
  13. * DS102: Remove unnecessary code created because of implicit returns
  14. * DS103: Rewrite code to no longer use __guard__
  15. * DS207: Consider shorter variations of null checks
  16. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  17. */
  18. let CompileManager
  19. const ResourceWriter = require('./ResourceWriter')
  20. const LatexRunner = require('./LatexRunner')
  21. const OutputFileFinder = require('./OutputFileFinder')
  22. const OutputCacheManager = require('./OutputCacheManager')
  23. const Settings = require('settings-sharelatex')
  24. const Path = require('path')
  25. const logger = require('logger-sharelatex')
  26. const Metrics = require('./Metrics')
  27. const child_process = require('child_process')
  28. const DraftModeManager = require('./DraftModeManager')
  29. const TikzManager = require('./TikzManager')
  30. const LockManager = require('./LockManager')
  31. const fs = require('fs')
  32. const fse = require('fs-extra')
  33. const os = require('os')
  34. const async = require('async')
  35. const Errors = require('./Errors')
  36. const CommandRunner = require('./CommandRunner')
  37. const getCompileName = function (project_id, user_id) {
  38. if (user_id != null) {
  39. return `${project_id}-${user_id}`
  40. } else {
  41. return project_id
  42. }
  43. }
  44. const getCompileDir = (project_id, user_id) =>
  45. Path.join(Settings.path.compilesDir, getCompileName(project_id, user_id))
  46. const getOutputDir = (project_id, user_id) =>
  47. Path.join(Settings.path.outputDir, getCompileName(project_id, user_id))
  48. module.exports = CompileManager = {
  49. doCompileWithLock(request, callback) {
  50. if (callback == null) {
  51. callback = function (error, outputFiles) {}
  52. }
  53. const compileDir = getCompileDir(request.project_id, request.user_id)
  54. const lockFile = Path.join(compileDir, '.project-lock')
  55. // use a .project-lock file in the compile directory to prevent
  56. // simultaneous compiles
  57. return fse.ensureDir(compileDir, function (error) {
  58. if (error != null) {
  59. return callback(error)
  60. }
  61. return LockManager.runWithLock(
  62. lockFile,
  63. (releaseLock) => CompileManager.doCompile(request, releaseLock),
  64. callback
  65. )
  66. })
  67. },
  68. doCompile(request, callback) {
  69. if (callback == null) {
  70. callback = function (error, outputFiles) {}
  71. }
  72. const compileDir = getCompileDir(request.project_id, request.user_id)
  73. const outputDir = getOutputDir(request.project_id, request.user_id)
  74. let timer = new Metrics.Timer('write-to-disk')
  75. logger.log(
  76. { project_id: request.project_id, user_id: request.user_id },
  77. 'syncing resources to disk'
  78. )
  79. return ResourceWriter.syncResourcesToDisk(request, compileDir, function (
  80. error,
  81. resourceList
  82. ) {
  83. // NOTE: resourceList is insecure, it should only be used to exclude files from the output list
  84. if (error != null && error instanceof Errors.FilesOutOfSyncError) {
  85. logger.warn(
  86. { project_id: request.project_id, user_id: request.user_id },
  87. 'files out of sync, please retry'
  88. )
  89. return callback(error)
  90. } else if (error != null) {
  91. logger.err(
  92. {
  93. err: error,
  94. project_id: request.project_id,
  95. user_id: request.user_id
  96. },
  97. 'error writing resources to disk'
  98. )
  99. return callback(error)
  100. }
  101. logger.log(
  102. {
  103. project_id: request.project_id,
  104. user_id: request.user_id,
  105. time_taken: Date.now() - timer.start
  106. },
  107. 'written files to disk'
  108. )
  109. timer.done()
  110. const injectDraftModeIfRequired = function (callback) {
  111. if (request.draft) {
  112. return DraftModeManager.injectDraftMode(
  113. Path.join(compileDir, request.rootResourcePath),
  114. callback
  115. )
  116. } else {
  117. return callback()
  118. }
  119. }
  120. const createTikzFileIfRequired = (callback) =>
  121. TikzManager.checkMainFile(
  122. compileDir,
  123. request.rootResourcePath,
  124. resourceList,
  125. function (error, needsMainFile) {
  126. if (error != null) {
  127. return callback(error)
  128. }
  129. if (needsMainFile) {
  130. return TikzManager.injectOutputFile(
  131. compileDir,
  132. request.rootResourcePath,
  133. callback
  134. )
  135. } else {
  136. return callback()
  137. }
  138. }
  139. )
  140. // set up environment variables for chktex
  141. const env = {}
  142. if (Settings.texliveOpenoutAny && Settings.texliveOpenoutAny !== '') {
  143. // override default texlive openout_any environment variable
  144. env.openout_any = Settings.texliveOpenoutAny
  145. }
  146. // only run chktex on LaTeX files (not knitr .Rtex files or any others)
  147. const isLaTeXFile =
  148. request.rootResourcePath != null
  149. ? request.rootResourcePath.match(/\.tex$/i)
  150. : undefined
  151. if (request.check != null && isLaTeXFile) {
  152. env.CHKTEX_OPTIONS = '-nall -e9 -e10 -w15 -w16'
  153. env.CHKTEX_ULIMIT_OPTIONS = '-t 5 -v 64000'
  154. if (request.check === 'error') {
  155. env.CHKTEX_EXIT_ON_ERROR = 1
  156. }
  157. if (request.check === 'validate') {
  158. env.CHKTEX_VALIDATE = 1
  159. }
  160. }
  161. // apply a series of file modifications/creations for draft mode and tikz
  162. return async.series(
  163. [injectDraftModeIfRequired, createTikzFileIfRequired],
  164. function (error) {
  165. if (error != null) {
  166. return callback(error)
  167. }
  168. timer = new Metrics.Timer('run-compile')
  169. // find the image tag to log it as a metric, e.g. 2015.1 (convert . to - for graphite)
  170. let tag =
  171. __guard__(
  172. __guard__(
  173. request.imageName != null
  174. ? request.imageName.match(/:(.*)/)
  175. : undefined,
  176. (x1) => x1[1]
  177. ),
  178. (x) => x.replace(/\./g, '-')
  179. ) || 'default'
  180. if (!request.project_id.match(/^[0-9a-f]{24}$/)) {
  181. tag = 'other'
  182. } // exclude smoke test
  183. Metrics.inc('compiles')
  184. Metrics.inc(`compiles-with-image.${tag}`)
  185. const compileName = getCompileName(
  186. request.project_id,
  187. request.user_id
  188. )
  189. return LatexRunner.runLatex(
  190. compileName,
  191. {
  192. directory: compileDir,
  193. mainFile: request.rootResourcePath,
  194. compiler: request.compiler,
  195. timeout: request.timeout,
  196. image: request.imageName,
  197. flags: request.flags,
  198. environment: env,
  199. compileGroup: request.compileGroup
  200. },
  201. function (error, output, stats, timings) {
  202. // request was for validation only
  203. let metric_key, metric_value
  204. if (request.check === 'validate') {
  205. const result = (error != null ? error.code : undefined)
  206. ? 'fail'
  207. : 'pass'
  208. error = new Error('validation')
  209. error.validate = result
  210. }
  211. // request was for compile, and failed on validation
  212. if (
  213. request.check === 'error' &&
  214. (error != null ? error.message : undefined) === 'exited'
  215. ) {
  216. error = new Error('compilation')
  217. error.validate = 'fail'
  218. }
  219. // compile was killed by user, was a validation, or a compile which failed validation
  220. if (
  221. (error != null ? error.terminated : undefined) ||
  222. (error != null ? error.validate : undefined) ||
  223. (error != null ? error.timedout : undefined)
  224. ) {
  225. OutputFileFinder.findOutputFiles(
  226. resourceList,
  227. compileDir,
  228. function (err, outputFiles) {
  229. if (err != null) {
  230. return callback(err)
  231. }
  232. error.outputFiles = outputFiles // return output files so user can check logs
  233. return callback(error)
  234. }
  235. )
  236. return
  237. }
  238. // compile completed normally
  239. if (error != null) {
  240. return callback(error)
  241. }
  242. Metrics.inc('compiles-succeeded')
  243. const object = stats || {}
  244. for (metric_key in object) {
  245. metric_value = object[metric_key]
  246. Metrics.count(metric_key, metric_value)
  247. }
  248. const object1 = timings || {}
  249. for (metric_key in object1) {
  250. metric_value = object1[metric_key]
  251. Metrics.timing(metric_key, metric_value)
  252. }
  253. const loadavg =
  254. typeof os.loadavg === 'function' ? os.loadavg() : undefined
  255. if (loadavg != null) {
  256. Metrics.gauge('load-avg', loadavg[0])
  257. }
  258. const ts = timer.done()
  259. logger.log(
  260. {
  261. project_id: request.project_id,
  262. user_id: request.user_id,
  263. time_taken: ts,
  264. stats,
  265. timings,
  266. loadavg
  267. },
  268. 'done compile'
  269. )
  270. if ((stats != null ? stats['latex-runs'] : undefined) > 0) {
  271. Metrics.timing('run-compile-per-pass', ts / stats['latex-runs'])
  272. }
  273. if (
  274. (stats != null ? stats['latex-runs'] : undefined) > 0 &&
  275. (timings != null ? timings['cpu-time'] : undefined) > 0
  276. ) {
  277. Metrics.timing(
  278. 'run-compile-cpu-time-per-pass',
  279. timings['cpu-time'] / stats['latex-runs']
  280. )
  281. }
  282. return OutputFileFinder.findOutputFiles(
  283. resourceList,
  284. compileDir,
  285. function (error, outputFiles) {
  286. if (error != null) {
  287. return callback(error)
  288. }
  289. return OutputCacheManager.saveOutputFiles(
  290. outputFiles,
  291. compileDir,
  292. outputDir,
  293. (error, newOutputFiles) => callback(null, newOutputFiles)
  294. )
  295. }
  296. )
  297. }
  298. )
  299. }
  300. )
  301. })
  302. },
  303. stopCompile(project_id, user_id, callback) {
  304. if (callback == null) {
  305. callback = function (error) {}
  306. }
  307. const compileName = getCompileName(project_id, user_id)
  308. return LatexRunner.killLatex(compileName, callback)
  309. },
  310. clearProject(project_id, user_id, _callback) {
  311. if (_callback == null) {
  312. _callback = function (error) {}
  313. }
  314. const callback = function (error) {
  315. _callback(error)
  316. return (_callback = function () {})
  317. }
  318. const compileDir = getCompileDir(project_id, user_id)
  319. const outputDir = getOutputDir(project_id, user_id)
  320. return CompileManager._checkDirectory(compileDir, function (err, exists) {
  321. if (err != null) {
  322. return callback(err)
  323. }
  324. if (!exists) {
  325. return callback()
  326. } // skip removal if no directory present
  327. const proc = child_process.spawn('rm', [
  328. '-r',
  329. '-f',
  330. '--',
  331. compileDir,
  332. outputDir
  333. ])
  334. proc.on('error', callback)
  335. let stderr = ''
  336. proc.stderr.setEncoding('utf8').on('data', (chunk) => (stderr += chunk))
  337. return proc.on('close', function (code) {
  338. if (code === 0) {
  339. return callback(null)
  340. } else {
  341. return callback(
  342. new Error(`rm -r ${compileDir} ${outputDir} failed: ${stderr}`)
  343. )
  344. }
  345. })
  346. })
  347. },
  348. _findAllDirs(callback) {
  349. if (callback == null) {
  350. callback = function (error, allDirs) {}
  351. }
  352. const root = Settings.path.compilesDir
  353. return fs.readdir(root, function (err, files) {
  354. if (err != null) {
  355. return callback(err)
  356. }
  357. const allDirs = Array.from(files).map((file) => Path.join(root, file))
  358. return callback(null, allDirs)
  359. })
  360. },
  361. clearExpiredProjects(max_cache_age_ms, callback) {
  362. if (callback == null) {
  363. callback = function (error) {}
  364. }
  365. const now = Date.now()
  366. // action for each directory
  367. const expireIfNeeded = (checkDir, cb) =>
  368. fs.stat(checkDir, function (err, stats) {
  369. if (err != null) {
  370. return cb()
  371. } // ignore errors checking directory
  372. const age = now - stats.mtime
  373. const hasExpired = age > max_cache_age_ms
  374. if (hasExpired) {
  375. return fse.remove(checkDir, cb)
  376. } else {
  377. return cb()
  378. }
  379. })
  380. // iterate over all project directories
  381. return CompileManager._findAllDirs(function (error, allDirs) {
  382. if (error != null) {
  383. return callback()
  384. }
  385. return async.eachSeries(allDirs, expireIfNeeded, callback)
  386. })
  387. },
  388. _checkDirectory(compileDir, callback) {
  389. if (callback == null) {
  390. callback = function (error, exists) {}
  391. }
  392. return fs.lstat(compileDir, function (err, stats) {
  393. if ((err != null ? err.code : undefined) === 'ENOENT') {
  394. return callback(null, false) // directory does not exist
  395. } else if (err != null) {
  396. logger.err(
  397. { dir: compileDir, err },
  398. 'error on stat of project directory for removal'
  399. )
  400. return callback(err)
  401. } else if (!(stats != null ? stats.isDirectory() : undefined)) {
  402. logger.err(
  403. { dir: compileDir, stats },
  404. 'bad project directory for removal'
  405. )
  406. return callback(new Error('project directory is not directory'))
  407. } else {
  408. return callback(null, true)
  409. }
  410. })
  411. }, // directory exists
  412. syncFromCode(project_id, user_id, file_name, line, column, callback) {
  413. // If LaTeX was run in a virtual environment, the file path that synctex expects
  414. // might not match the file path on the host. The .synctex.gz file however, will be accessed
  415. // wherever it is on the host.
  416. if (callback == null) {
  417. callback = function (error, pdfPositions) {}
  418. }
  419. const compileName = getCompileName(project_id, user_id)
  420. const base_dir = Settings.path.synctexBaseDir(compileName)
  421. const file_path = base_dir + '/' + file_name
  422. const compileDir = getCompileDir(project_id, user_id)
  423. const synctex_path = `${base_dir}/output.pdf`
  424. const command = ['code', synctex_path, file_path, line, column]
  425. CompileManager._runSynctex(project_id, user_id, command, function (
  426. error,
  427. stdout
  428. ) {
  429. if (error != null) {
  430. return callback(error)
  431. }
  432. logger.log(
  433. { project_id, user_id, file_name, line, column, command, stdout },
  434. 'synctex code output'
  435. )
  436. return callback(null, CompileManager._parseSynctexFromCodeOutput(stdout))
  437. })
  438. },
  439. syncFromPdf(project_id, user_id, page, h, v, callback) {
  440. if (callback == null) {
  441. callback = function (error, filePositions) {}
  442. }
  443. const compileName = getCompileName(project_id, user_id)
  444. const compileDir = getCompileDir(project_id, user_id)
  445. const base_dir = Settings.path.synctexBaseDir(compileName)
  446. const synctex_path = `${base_dir}/output.pdf`
  447. const command = ['pdf', synctex_path, page, h, v]
  448. CompileManager._runSynctex(project_id, user_id, command, function (
  449. error,
  450. stdout
  451. ) {
  452. if (error != null) {
  453. return callback(error)
  454. }
  455. logger.log(
  456. { project_id, user_id, page, h, v, stdout },
  457. 'synctex pdf output'
  458. )
  459. return callback(
  460. null,
  461. CompileManager._parseSynctexFromPdfOutput(stdout, base_dir)
  462. )
  463. })
  464. },
  465. _checkFileExists(dir, filename, callback) {
  466. if (callback == null) {
  467. callback = function (error) {}
  468. }
  469. const file = Path.join(dir, filename)
  470. return fs.stat(dir, function (error, stats) {
  471. if ((error != null ? error.code : undefined) === 'ENOENT') {
  472. return callback(new Errors.NotFoundError('no output directory'))
  473. }
  474. if (error != null) {
  475. return callback(error)
  476. }
  477. return fs.stat(file, function (error, stats) {
  478. if ((error != null ? error.code : undefined) === 'ENOENT') {
  479. return callback(new Errors.NotFoundError('no output file'))
  480. }
  481. if (error != null) {
  482. return callback(error)
  483. }
  484. if (!(stats != null ? stats.isFile() : undefined)) {
  485. return callback(new Error('not a file'))
  486. }
  487. return callback()
  488. })
  489. })
  490. },
  491. _runSynctex(project_id, user_id, command, callback) {
  492. if (callback == null) {
  493. callback = function (error, stdout) {}
  494. }
  495. const seconds = 1000
  496. command.unshift('/opt/synctex')
  497. const directory = getCompileDir(project_id, user_id)
  498. const timeout = 60 * 1000 // increased to allow for large projects
  499. const compileName = getCompileName(project_id, user_id)
  500. const compileGroup = 'synctex'
  501. CompileManager._checkFileExists(directory, 'output.synctex.gz', (error) => {
  502. if (error) {
  503. return callback(error)
  504. }
  505. return CommandRunner.run(
  506. compileName,
  507. command,
  508. directory,
  509. Settings.clsi && Settings.clsi.docker
  510. ? Settings.clsi.docker.image
  511. : undefined,
  512. timeout,
  513. {},
  514. compileGroup,
  515. function (error, output) {
  516. if (error != null) {
  517. logger.err(
  518. { err: error, command, project_id, user_id },
  519. 'error running synctex'
  520. )
  521. return callback(error)
  522. }
  523. return callback(null, output.stdout)
  524. }
  525. )
  526. })
  527. },
  528. _parseSynctexFromCodeOutput(output) {
  529. const results = []
  530. for (const line of Array.from(output.split('\n'))) {
  531. const [node, page, h, v, width, height] = Array.from(line.split('\t'))
  532. if (node === 'NODE') {
  533. results.push({
  534. page: parseInt(page, 10),
  535. h: parseFloat(h),
  536. v: parseFloat(v),
  537. height: parseFloat(height),
  538. width: parseFloat(width)
  539. })
  540. }
  541. }
  542. return results
  543. },
  544. _parseSynctexFromPdfOutput(output, base_dir) {
  545. const results = []
  546. for (let line of Array.from(output.split('\n'))) {
  547. let column, file_path, node
  548. ;[node, file_path, line, column] = Array.from(line.split('\t'))
  549. if (node === 'NODE') {
  550. const file = file_path.slice(base_dir.length + 1)
  551. results.push({
  552. file,
  553. line: parseInt(line, 10),
  554. column: parseInt(column, 10)
  555. })
  556. }
  557. }
  558. return results
  559. },
  560. wordcount(project_id, user_id, file_name, image, callback) {
  561. if (callback == null) {
  562. callback = function (error, pdfPositions) {}
  563. }
  564. logger.log({ project_id, user_id, file_name, image }, 'running wordcount')
  565. const file_path = `$COMPILE_DIR/${file_name}`
  566. const command = [
  567. 'texcount',
  568. '-nocol',
  569. '-inc',
  570. file_path,
  571. `-out=${file_path}.wc`
  572. ]
  573. const compileDir = getCompileDir(project_id, user_id)
  574. const timeout = 60 * 1000
  575. const compileName = getCompileName(project_id, user_id)
  576. const compileGroup = 'wordcount'
  577. return fse.ensureDir(compileDir, function (error) {
  578. if (error != null) {
  579. logger.err(
  580. { error, project_id, user_id, file_name },
  581. 'error ensuring dir for sync from code'
  582. )
  583. return callback(error)
  584. }
  585. return CommandRunner.run(
  586. compileName,
  587. command,
  588. compileDir,
  589. image,
  590. timeout,
  591. {},
  592. compileGroup,
  593. function (error) {
  594. if (error != null) {
  595. return callback(error)
  596. }
  597. return fs.readFile(
  598. compileDir + '/' + file_name + '.wc',
  599. 'utf-8',
  600. function (err, stdout) {
  601. if (err != null) {
  602. // call it node_err so sentry doesn't use random path error as unique id so it can't be ignored
  603. logger.err(
  604. { node_err: err, command, compileDir, project_id, user_id },
  605. 'error reading word count output'
  606. )
  607. return callback(err)
  608. }
  609. const results = CompileManager._parseWordcountFromOutput(stdout)
  610. logger.log(
  611. { project_id, user_id, wordcount: results },
  612. 'word count results'
  613. )
  614. return callback(null, results)
  615. }
  616. )
  617. }
  618. )
  619. })
  620. },
  621. _parseWordcountFromOutput(output) {
  622. const results = {
  623. encode: '',
  624. textWords: 0,
  625. headWords: 0,
  626. outside: 0,
  627. headers: 0,
  628. elements: 0,
  629. mathInline: 0,
  630. mathDisplay: 0,
  631. errors: 0,
  632. messages: ''
  633. }
  634. for (const line of Array.from(output.split('\n'))) {
  635. const [data, info] = Array.from(line.split(':'))
  636. if (data.indexOf('Encoding') > -1) {
  637. results.encode = info.trim()
  638. }
  639. if (data.indexOf('in text') > -1) {
  640. results.textWords = parseInt(info, 10)
  641. }
  642. if (data.indexOf('in head') > -1) {
  643. results.headWords = parseInt(info, 10)
  644. }
  645. if (data.indexOf('outside') > -1) {
  646. results.outside = parseInt(info, 10)
  647. }
  648. if (data.indexOf('of head') > -1) {
  649. results.headers = parseInt(info, 10)
  650. }
  651. if (data.indexOf('Number of floats/tables/figures') > -1) {
  652. results.elements = parseInt(info, 10)
  653. }
  654. if (data.indexOf('Number of math inlines') > -1) {
  655. results.mathInline = parseInt(info, 10)
  656. }
  657. if (data.indexOf('Number of math displayed') > -1) {
  658. results.mathDisplay = parseInt(info, 10)
  659. }
  660. if (data === '(errors') {
  661. // errors reported as (errors:123)
  662. results.errors = parseInt(info, 10)
  663. }
  664. if (line.indexOf('!!! ') > -1) {
  665. // errors logged as !!! message !!!
  666. results.messages += line + '\n'
  667. }
  668. }
  669. return results
  670. }
  671. }
  672. function __guard__(value, transform) {
  673. return typeof value !== 'undefined' && value !== null
  674. ? transform(value)
  675. : undefined
  676. }