CompileManager.js 17 KB

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