[clsi] handle draft mode and tikzexternalize as part of sync phase (#32516)

* [clsi] handle draft mode and tikzexternalize as part of sync phase

* [clsi] emit empty string from SafeReader on ENOENT

* [clsi] persist history state after clearing dirty state without changes

GitOrigin-RevId: d9dcd2e6887017f7935b5e95bdbdc6e11a3b18f5
This commit is contained in:
Jakob Ackermann
2026-03-30 13:50:08 +02:00
committed by Copybot
parent 0059928f24
commit 0544aded40
5 changed files with 96 additions and 48 deletions

View File

@@ -122,6 +122,25 @@ async function doCompile(request, stats, timings) {
request,
compileDir
)
// apply a series of file modifications/creations for draft mode and tikz
if (request.draft) {
await DraftModeManager.promises.injectDraftMode(
Path.join(compileDir, request.rootResourcePath)
)
}
const needsMainFile = await TikzManager.promises.checkMainFile(
compileDir,
request.rootResourcePath,
resourceList
)
if (needsMainFile) {
await TikzManager.promises.injectOutputFile(
compileDir,
request.rootResourcePath
)
}
}
} catch (error) {
if (error instanceof Errors.FilesOutOfSyncError) {
@@ -173,25 +192,6 @@ async function doCompile(request, stats, timings) {
}
}
// apply a series of file modifications/creations for draft mode and tikz
if (request.draft) {
await DraftModeManager.promises.injectDraftMode(
Path.join(compileDir, request.rootResourcePath)
)
}
const needsMainFile = await TikzManager.promises.checkMainFile(
compileDir,
request.rootResourcePath,
resourceList
)
if (needsMainFile) {
await TikzManager.promises.injectOutputFile(
compileDir,
request.rootResourcePath
)
}
const compileStart = Date.now()
const compileName = getCompileName(request.project_id, request.user_id)

View File

@@ -2,11 +2,12 @@ import fsPromises from 'node:fs/promises'
import { callbackify } from 'node:util'
import logger from '@overleaf/logger'
const PREFIX =
'\\PassOptionsToPackage{draft}{graphicx}\\PassOptionsToPackage{draft}{graphics}'
async function injectDraftMode(filename) {
const content = await fsPromises.readFile(filename, { encoding: 'utf8' })
const modifiedContent =
'\\PassOptionsToPackage{draft}{graphicx}\\PassOptionsToPackage{draft}{graphics}' +
content
const modifiedContent = PREFIX + content
logger.debug(
{
content: content.slice(0, 1024), // \documentclass is normally v near the top
@@ -19,6 +20,7 @@ async function injectDraftMode(filename) {
}
export default {
PREFIX,
injectDraftMode: callbackify(injectDraftMode),
promises: { injectDraftMode },
}

View File

@@ -23,6 +23,8 @@ import OError from '@overleaf/o-error'
import ClsiMetrics from './Metrics.js'
import { promiseMapSettledWithLimit } from '@overleaf/promise-utils'
import Metrics from '@overleaf/metrics'
import TikzManager from './TikzManager.js'
import DraftModeManager from './DraftModeManager.js'
const gzip = promisify(zlib.gzip)
const gunzip = promisify(zlib.gunzip)
@@ -76,7 +78,7 @@ function isENOENT(err) {
* @param {string} userId
* @param {number} remoteBaseVersion
* @param {boolean} populateClsiCache
* @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], fullSync: boolean,localBaseVersion: number}>}
* @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], fullSync: boolean,localBaseVersion: number, dirty: string[]}>}
*/
async function loadSnapshot(
projectId,
@@ -134,7 +136,7 @@ async function loadSnapshot(
* @param {string} projectId
* @param {string} userId
* @param {number} remoteBaseVersion
* @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], fullSync: boolean,localBaseVersion: number}>}
* @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], fullSync: boolean,localBaseVersion: number, dirty: string[]}>}
*/
async function loadSnapshotFromClsiCache(projectId, userId, remoteBaseVersion) {
const { dir, resyncPath } = snapshotPath(projectId, userId)
@@ -160,20 +162,23 @@ async function loadSnapshotFromClsiCache(projectId, userId, remoteBaseVersion) {
* @param {string} path
* @param {number} remoteBaseVersion
* @param {boolean} fullSync
* @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], localBaseVersion: number, fullSync: boolean}>}
* @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], localBaseVersion: number, fullSync: boolean, dirty: string[]}>}
*/
async function loadSnapshotFromFile(path, remoteBaseVersion, fullSync) {
let blob = await fs.promises.readFile(path)
blob = await gunzip(blob)
const { rawSnapshot, globalBlobs, localBaseVersion } = JSON.parse(
blob.toString('utf-8')
)
const {
rawSnapshot,
globalBlobs,
localBaseVersion,
dirty = [], // added later, provide a default value.
} = JSON.parse(blob.toString('utf-8'))
if (localBaseVersion < remoteBaseVersion) {
throw new Errors.MissingUpdatesError('missing updates', {
baseHistoryVersion: localBaseVersion,
})
}
return { rawSnapshot, globalBlobs, localBaseVersion, fullSync }
return { rawSnapshot, globalBlobs, localBaseVersion, fullSync, dirty }
}
/**
@@ -182,6 +187,7 @@ async function loadSnapshotFromFile(path, remoteBaseVersion, fullSync) {
* @param {Snapshot} snapshot
* @param {number} localBaseVersion
* @param {string[]} globalBlobs
* @param {string[]} dirty
* @return {Promise<void>}
*/
async function saveSnapshot(
@@ -189,7 +195,8 @@ async function saveSnapshot(
userId,
snapshot,
localBaseVersion,
globalBlobs
globalBlobs,
dirty
) {
const { dir, path } = snapshotPath(projectId, userId)
await fs.promises.mkdir(dir, { recursive: true })
@@ -201,6 +208,7 @@ async function saveSnapshot(
globalBlobs,
localBaseVersion,
rawSnapshot: snapshot.toRaw(),
dirty,
}),
// use cheapest gzip compression level
{ level: 1 }
@@ -360,10 +368,9 @@ export async function syncResourcesToDisk(
timings
) {
const remoteBaseVersion = request.baseHistoryVersion
let rawSnapshot, globalBlobs, localBaseVersion, source
let fullSync = true
let rawSnapshot, globalBlobs, localBaseVersion, source, dirty, fullSync
try {
;({ rawSnapshot, globalBlobs, fullSync, localBaseVersion } =
;({ rawSnapshot, globalBlobs, fullSync, localBaseVersion, dirty } =
await loadSnapshot(
projectId,
userId,
@@ -391,6 +398,8 @@ export async function syncResourcesToDisk(
localBaseVersion = remoteBaseVersion
rawSnapshot = request.rawSnapshot
globalBlobs = []
dirty = []
fullSync = true
}
globalBlobs = Array.from(new Set(globalBlobs.concat(request.globalBlobs)))
@@ -417,7 +426,10 @@ export async function syncResourcesToDisk(
changedPaths.push(...snapshot.getFilePathnames())
logger.debug({ projectId, userId }, 'compile from cache: full sync')
} else {
const dedupe = new Set()
const dedupe = new Set(dirty)
if (request.draft) {
dedupe.add(request.rootResourcePath)
}
for (const change of changes) {
for (const operation of change.getOperations()) {
if (operation instanceof AddFileOperation) {
@@ -462,6 +474,8 @@ export async function syncResourcesToDisk(
await ensureHasParentFolder(compileDir, path, entriesDepthFirst)
}
const wasDirty = dirty.length > 0
dirty = []
let createCacheFolder
// Use Promise.allSettled to ensure that all writes have stopped when we exit.
const allDone = await promiseMapSettledWithLimit(
@@ -471,8 +485,19 @@ export async function syncResourcesToDisk(
const file = snapshot.getFile(path)
if (!file) return // deleted, handled by removeExtraneousEntries
const content = file.getContent({ filterTrackedDeletes: true })
let content = file.getContent({ filterTrackedDeletes: true })
if (typeof content === 'string') {
if (path === request.rootResourcePath) {
if (request.draft) {
content = DraftModeManager.PREFIX + content
dirty.push(path)
}
await TikzManager.writeOutputFileIfNeeded(
compileDir,
snapshot,
content
)
}
await fs.promises.writeFile(
Path.join(compileDir, path),
content,
@@ -514,13 +539,14 @@ export async function syncResourcesToDisk(
throw OError.tag(result.reason, 'write failed', { path })
}
const baseHistoryVersion = localBaseVersion + changes.length
if (fullSync || changes.length) {
if (fullSync || changes.length || wasDirty || dirty.length) {
await saveSnapshot(
projectId,
userId,
snapshot,
baseHistoryVersion,
globalBlobs
globalBlobs,
dirty
)
}
if (fullSync) {

View File

@@ -26,7 +26,7 @@ export default SafeReader = {
}
return fs.open(file, 'r', function (err, fd) {
if (err != null && err.code === 'ENOENT') {
return callback()
return callback(null, '', 0)
}
if (err != null) {
return callback(err)

View File

@@ -23,13 +23,39 @@ let TikzManager
// copy of the main file as 'output.tex'.
export default TikzManager = {
OUTPUT_TEX: 'output.tex',
/**
* @param {string} content
* @return {boolean}
*/
usesTikzExternalize(content) {
return content.includes('\\tikzexternalize') || content.includes('{pstool}')
},
/**
* @param {string} compileDir
* @param {import('overleaf-editor-core').Snapshot} snapshot
* @param {string} content
* @return {Promise<void>}
*/
async writeOutputFileIfNeeded(compileDir, snapshot, content) {
if (snapshot.getFile(TikzManager.OUTPUT_TEX)) return
if (!TikzManager.usesTikzExternalize(content)) return
await fs.promises.writeFile(
Path.join(compileDir, TikzManager.OUTPUT_TEX),
content,
'utf-8'
)
},
checkMainFile(compileDir, mainFile, resources, callback) {
// if there's already an output.tex file, we don't want to touch it
if (callback == null) {
callback = function () {}
}
for (const resource of Array.from(resources)) {
if (resource.path === 'output.tex') {
if (resource.path === TikzManager.OUTPUT_TEX) {
logger.debug(
{ compileDir, mainFile },
'output.tex already in resources'
@@ -53,17 +79,11 @@ export default TikzManager = {
if (error != null) {
return callback(error)
}
const usesTikzExternalize =
(content != null
? content.indexOf('\\tikzexternalize')
: undefined) >= 0
const usesPsTool =
(content != null ? content.indexOf('{pstool}') : undefined) >= 0
const needsMainFile = TikzManager.usesTikzExternalize(content)
logger.debug(
{ compileDir, mainFile, usesTikzExternalize, usesPsTool },
{ compileDir, mainFile, needsMainFile },
'checked for packages needing main file as output.tex'
)
const needsMainFile = usesTikzExternalize || usesPsTool
return callback(null, needsMainFile)
}
)
@@ -92,7 +112,7 @@ export default TikzManager = {
)
// use wx flag to ensure that output file does not already exist
return fs.writeFile(
Path.join(compileDir, 'output.tex'),
Path.join(compileDir, TikzManager.OUTPUT_TEX),
content,
{ flag: 'wx' },
callback