CompileManager.js 19 KB

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