CompileManager.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
  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. return CompileManager._checkDirectory(compileDir, function (err, exists) {
  320. if (err != null) {
  321. return callback(err)
  322. }
  323. if (!exists) {
  324. return callback()
  325. } // skip removal if no directory present
  326. const proc = child_process.spawn('rm', ['-r', compileDir])
  327. proc.on('error', callback)
  328. let stderr = ''
  329. proc.stderr.setEncoding('utf8').on('data', (chunk) => (stderr += chunk))
  330. return proc.on('close', function (code) {
  331. if (code === 0) {
  332. return callback(null)
  333. } else {
  334. return callback(new Error(`rm -r ${compileDir} failed: ${stderr}`))
  335. }
  336. })
  337. })
  338. },
  339. _findAllDirs(callback) {
  340. if (callback == null) {
  341. callback = function (error, allDirs) {}
  342. }
  343. const root = Settings.path.compilesDir
  344. return fs.readdir(root, function (err, files) {
  345. if (err != null) {
  346. return callback(err)
  347. }
  348. const allDirs = Array.from(files).map((file) => Path.join(root, file))
  349. return callback(null, allDirs)
  350. })
  351. },
  352. clearExpiredProjects(max_cache_age_ms, callback) {
  353. if (callback == null) {
  354. callback = function (error) {}
  355. }
  356. const now = Date.now()
  357. // action for each directory
  358. const expireIfNeeded = (checkDir, cb) =>
  359. fs.stat(checkDir, function (err, stats) {
  360. if (err != null) {
  361. return cb()
  362. } // ignore errors checking directory
  363. const age = now - stats.mtime
  364. const hasExpired = age > max_cache_age_ms
  365. if (hasExpired) {
  366. return fse.remove(checkDir, cb)
  367. } else {
  368. return cb()
  369. }
  370. })
  371. // iterate over all project directories
  372. return CompileManager._findAllDirs(function (error, allDirs) {
  373. if (error != null) {
  374. return callback()
  375. }
  376. return async.eachSeries(allDirs, expireIfNeeded, callback)
  377. })
  378. },
  379. _checkDirectory(compileDir, callback) {
  380. if (callback == null) {
  381. callback = function (error, exists) {}
  382. }
  383. return fs.lstat(compileDir, function (err, stats) {
  384. if ((err != null ? err.code : undefined) === 'ENOENT') {
  385. return callback(null, false) // directory does not exist
  386. } else if (err != null) {
  387. logger.err(
  388. { dir: compileDir, err },
  389. 'error on stat of project directory for removal'
  390. )
  391. return callback(err)
  392. } else if (!(stats != null ? stats.isDirectory() : undefined)) {
  393. logger.err(
  394. { dir: compileDir, stats },
  395. 'bad project directory for removal'
  396. )
  397. return callback(new Error('project directory is not directory'))
  398. } else {
  399. return callback(null, true)
  400. }
  401. })
  402. }, // directory exists
  403. syncFromCode(project_id, user_id, file_name, line, column, callback) {
  404. // If LaTeX was run in a virtual environment, the file path that synctex expects
  405. // might not match the file path on the host. The .synctex.gz file however, will be accessed
  406. // wherever it is on the host.
  407. if (callback == null) {
  408. callback = function (error, pdfPositions) {}
  409. }
  410. const compileName = getCompileName(project_id, user_id)
  411. const base_dir = Settings.path.synctexBaseDir(compileName)
  412. const file_path = base_dir + '/' + file_name
  413. const compileDir = getCompileDir(project_id, user_id)
  414. const synctex_path = `${base_dir}/output.pdf`
  415. const command = ['code', synctex_path, file_path, line, column]
  416. CompileManager._runSynctex(project_id, user_id, command, function (
  417. error,
  418. stdout
  419. ) {
  420. if (error != null) {
  421. return callback(error)
  422. }
  423. logger.log(
  424. { project_id, user_id, file_name, line, column, command, stdout },
  425. 'synctex code output'
  426. )
  427. return callback(null, CompileManager._parseSynctexFromCodeOutput(stdout))
  428. })
  429. },
  430. syncFromPdf(project_id, user_id, page, h, v, callback) {
  431. if (callback == null) {
  432. callback = function (error, filePositions) {}
  433. }
  434. const compileName = getCompileName(project_id, user_id)
  435. const compileDir = getCompileDir(project_id, user_id)
  436. const base_dir = Settings.path.synctexBaseDir(compileName)
  437. const synctex_path = `${base_dir}/output.pdf`
  438. const command = ['pdf', synctex_path, page, h, v]
  439. CompileManager._runSynctex(project_id, user_id, command, function (
  440. error,
  441. stdout
  442. ) {
  443. if (error != null) {
  444. return callback(error)
  445. }
  446. logger.log(
  447. { project_id, user_id, page, h, v, stdout },
  448. 'synctex pdf output'
  449. )
  450. return callback(
  451. null,
  452. CompileManager._parseSynctexFromPdfOutput(stdout, base_dir)
  453. )
  454. })
  455. },
  456. _checkFileExists(dir, filename, callback) {
  457. if (callback == null) {
  458. callback = function (error) {}
  459. }
  460. const file = Path.join(dir, filename)
  461. return fs.stat(dir, function (error, stats) {
  462. if ((error != null ? error.code : undefined) === 'ENOENT') {
  463. return callback(new Errors.NotFoundError('no output directory'))
  464. }
  465. if (error != null) {
  466. return callback(error)
  467. }
  468. return fs.stat(file, function (error, stats) {
  469. if ((error != null ? error.code : undefined) === 'ENOENT') {
  470. return callback(new Errors.NotFoundError('no output file'))
  471. }
  472. if (error != null) {
  473. return callback(error)
  474. }
  475. if (!(stats != null ? stats.isFile() : undefined)) {
  476. return callback(new Error('not a file'))
  477. }
  478. return callback()
  479. })
  480. })
  481. },
  482. _runSynctex(project_id, user_id, command, callback) {
  483. if (callback == null) {
  484. callback = function (error, stdout) {}
  485. }
  486. const seconds = 1000
  487. command.unshift('/opt/synctex')
  488. const directory = getCompileDir(project_id, user_id)
  489. const timeout = 60 * 1000 // increased to allow for large projects
  490. const compileName = getCompileName(project_id, user_id)
  491. const compileGroup = 'synctex'
  492. CompileManager._checkFileExists(directory, 'output.synctex.gz', (error) => {
  493. if (error) {
  494. return callback(error)
  495. }
  496. return CommandRunner.run(
  497. compileName,
  498. command,
  499. directory,
  500. Settings.clsi && Settings.clsi.docker
  501. ? Settings.clsi.docker.image
  502. : undefined,
  503. timeout,
  504. {},
  505. compileGroup,
  506. function (error, output) {
  507. if (error != null) {
  508. logger.err(
  509. { err: error, command, project_id, user_id },
  510. 'error running synctex'
  511. )
  512. return callback(error)
  513. }
  514. return callback(null, output.stdout)
  515. }
  516. )
  517. })
  518. },
  519. _parseSynctexFromCodeOutput(output) {
  520. const results = []
  521. for (const line of Array.from(output.split('\n'))) {
  522. const [node, page, h, v, width, height] = Array.from(line.split('\t'))
  523. if (node === 'NODE') {
  524. results.push({
  525. page: parseInt(page, 10),
  526. h: parseFloat(h),
  527. v: parseFloat(v),
  528. height: parseFloat(height),
  529. width: parseFloat(width)
  530. })
  531. }
  532. }
  533. return results
  534. },
  535. _parseSynctexFromPdfOutput(output, base_dir) {
  536. const results = []
  537. for (let line of Array.from(output.split('\n'))) {
  538. let column, file_path, node
  539. ;[node, file_path, line, column] = Array.from(line.split('\t'))
  540. if (node === 'NODE') {
  541. const file = file_path.slice(base_dir.length + 1)
  542. results.push({
  543. file,
  544. line: parseInt(line, 10),
  545. column: parseInt(column, 10)
  546. })
  547. }
  548. }
  549. return results
  550. },
  551. wordcount(project_id, user_id, file_name, image, callback) {
  552. if (callback == null) {
  553. callback = function (error, pdfPositions) {}
  554. }
  555. logger.log({ project_id, user_id, file_name, image }, 'running wordcount')
  556. const file_path = `$COMPILE_DIR/${file_name}`
  557. const command = [
  558. 'texcount',
  559. '-nocol',
  560. '-inc',
  561. file_path,
  562. `-out=${file_path}.wc`
  563. ]
  564. const compileDir = getCompileDir(project_id, user_id)
  565. const timeout = 60 * 1000
  566. const compileName = getCompileName(project_id, user_id)
  567. const compileGroup = 'wordcount'
  568. return fse.ensureDir(compileDir, function (error) {
  569. if (error != null) {
  570. logger.err(
  571. { error, project_id, user_id, file_name },
  572. 'error ensuring dir for sync from code'
  573. )
  574. return callback(error)
  575. }
  576. return CommandRunner.run(
  577. compileName,
  578. command,
  579. compileDir,
  580. image,
  581. timeout,
  582. {},
  583. compileGroup,
  584. function (error) {
  585. if (error != null) {
  586. return callback(error)
  587. }
  588. return fs.readFile(
  589. compileDir + '/' + file_name + '.wc',
  590. 'utf-8',
  591. function (err, stdout) {
  592. if (err != null) {
  593. // call it node_err so sentry doesn't use random path error as unique id so it can't be ignored
  594. logger.err(
  595. { node_err: err, command, compileDir, project_id, user_id },
  596. 'error reading word count output'
  597. )
  598. return callback(err)
  599. }
  600. const results = CompileManager._parseWordcountFromOutput(stdout)
  601. logger.log(
  602. { project_id, user_id, wordcount: results },
  603. 'word count results'
  604. )
  605. return callback(null, results)
  606. }
  607. )
  608. }
  609. )
  610. })
  611. },
  612. _parseWordcountFromOutput(output) {
  613. const results = {
  614. encode: '',
  615. textWords: 0,
  616. headWords: 0,
  617. outside: 0,
  618. headers: 0,
  619. elements: 0,
  620. mathInline: 0,
  621. mathDisplay: 0,
  622. errors: 0,
  623. messages: ''
  624. }
  625. for (const line of Array.from(output.split('\n'))) {
  626. const [data, info] = Array.from(line.split(':'))
  627. if (data.indexOf('Encoding') > -1) {
  628. results.encode = info.trim()
  629. }
  630. if (data.indexOf('in text') > -1) {
  631. results.textWords = parseInt(info, 10)
  632. }
  633. if (data.indexOf('in head') > -1) {
  634. results.headWords = parseInt(info, 10)
  635. }
  636. if (data.indexOf('outside') > -1) {
  637. results.outside = parseInt(info, 10)
  638. }
  639. if (data.indexOf('of head') > -1) {
  640. results.headers = parseInt(info, 10)
  641. }
  642. if (data.indexOf('Number of floats/tables/figures') > -1) {
  643. results.elements = parseInt(info, 10)
  644. }
  645. if (data.indexOf('Number of math inlines') > -1) {
  646. results.mathInline = parseInt(info, 10)
  647. }
  648. if (data.indexOf('Number of math displayed') > -1) {
  649. results.mathDisplay = parseInt(info, 10)
  650. }
  651. if (data === '(errors') {
  652. // errors reported as (errors:123)
  653. results.errors = parseInt(info, 10)
  654. }
  655. if (line.indexOf('!!! ') > -1) {
  656. // errors logged as !!! message !!!
  657. results.messages += line + '\n'
  658. }
  659. }
  660. return results
  661. }
  662. }
  663. function __guard__(value, transform) {
  664. return typeof value !== 'undefined' && value !== null
  665. ? transform(value)
  666. : undefined
  667. }