texlite 0.6.0 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +1 -0
  2. package/dist/client/assets/{BinaryModule-Aj1vLnwf-C2TCdTPg.js → BinaryModule-Aj1vLnwf-hZ2K_uyt.js} +1 -1
  3. package/dist/client/assets/{GitDialog-9mNxx8oX.js → GitDialog-s54eAFX6.js} +1 -1
  4. package/dist/client/assets/{HistoryDialog-CmoD9Cvf.js → HistoryDialog-DrUnIy0a.js} +1 -1
  5. package/dist/client/assets/{LatexEditor-Dp8EoZBP.js → LatexEditor-Y5KfVqQI.js} +20 -20
  6. package/dist/client/assets/PdfPreview-BPKX-qpr.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-veKVnRxc.js → ProjectNavigationDialogs-CjZX0g7O.js} +1 -1
  8. package/dist/client/assets/{SystemMetricsDialog-D_oiVmBC.js → SystemMetricsDialog-B97Og0La.js} +1 -1
  9. package/dist/client/assets/binary-DCBFVo4B.js +1 -0
  10. package/dist/client/assets/index-5ajAy27z.css +1 -0
  11. package/dist/client/assets/index-CCitTPyj.js +17 -0
  12. package/dist/client/assets/{index-CyEG4l54.js → index-CQG_gpuU.js} +1 -1
  13. package/dist/client/assets/{plus-Bup73Wq6.js → plus-Bp4_lUor.js} +1 -1
  14. package/dist/client/assets/{spellCheck-BHW5bumN.js → spellCheck-0f5ZG5IV.js} +3 -3
  15. package/dist/client/index.html +2 -2
  16. package/dist/server/app.js +888 -339
  17. package/dist/server/archive.js +20 -3
  18. package/dist/server/auth.js +13 -2
  19. package/dist/server/collaboration.js +815 -124
  20. package/dist/server/compileArtifacts.js +31 -13
  21. package/dist/server/compiler.js +69 -20
  22. package/dist/server/db.js +6 -0
  23. package/dist/server/environment.js +4 -2
  24. package/dist/server/files.js +172 -25
  25. package/dist/server/git.js +152 -124
  26. package/dist/server/history.js +86 -18
  27. package/dist/server/http.js +26 -0
  28. package/dist/server/index.js +39 -26
  29. package/dist/server/instanceLock.js +137 -0
  30. package/dist/server/latexCompletion.js +23 -1
  31. package/dist/server/latexFormatter.js +4 -2
  32. package/dist/server/processTree.js +31 -0
  33. package/dist/server/projectMutations.js +255 -0
  34. package/dist/server/projectOutline.js +113 -1
  35. package/dist/server/projectSearch.js +6 -0
  36. package/dist/server/routes/compile.js +505 -128
  37. package/dist/server/security.js +61 -1
  38. package/dist/server/synctex.js +11 -2
  39. package/dist/server/zip.js +2 -1
  40. package/package.json +1 -1
  41. package/dist/client/assets/PdfPreview-IfkmLMSU.js +0 -1
  42. package/dist/client/assets/binary-DTriWQzf.js +0 -1
  43. package/dist/client/assets/index-CI87C5Wy.js +0 -17
  44. package/dist/client/assets/index-DB6GC33g.css +0 -1
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { performance } from "node:perf_hooks";
5
5
  import { requireUser } from "../auth.js";
6
6
  import { accessibleProject, canEdit } from "../projects.js";
7
- import { resolveSourcePath, safeRelativePath } from "../files.js";
7
+ import { resolveSourcePath, safeRelativePath, texFileStem } from "../files.js";
8
8
  import { captureCompileSnapshot, cleanCompileArtifacts, cleanCompileCache, compileProject, discardCompileSnapshot, hasCompileCache, publishCompileArtifacts, publishedCompileArtifacts } from "../compiler.js";
9
9
  import { parseCompileDiagnostics } from "../compileDiagnostics.js";
10
10
  import { pdfToSource, sourceToPdf } from "../synctex.js";
@@ -12,8 +12,20 @@ import { availablePdf, compileRunPdf, compileMainFile, compilePdfUrl, isTextComp
12
12
  import { apiError, contentDisposition } from "../http.js";
13
13
  const now = () => new Date().toISOString();
14
14
  const timingDuration = (milliseconds) => Math.round(milliseconds * 10) / 10;
15
+ const MAX_SNAPSHOT_ATTEMPTS = 3;
16
+ const SNAPSHOT_RETRY_AFTER_SECONDS = 1;
17
+ function mainDocumentChangedError() {
18
+ return Object.assign(new Error("项目主文档在编译排队期间发生变化,请重新发起编译"), { statusCode: 409, code: "MAIN_DOCUMENT_CHANGED" });
19
+ }
20
+ class CompileSnapshotBusyError extends Error {
21
+ code = "COMPILE_SNAPSHOT_BUSY";
22
+ constructor() {
23
+ super("项目源码尚未完成稳定保存,暂时无法编译,请稍后重试");
24
+ this.name = "CompileSnapshotBusyError";
25
+ }
26
+ }
15
27
  export function registerCompileRoutes(app, context) {
16
- const { config, db, collaboration, compileCoordinator, metrics, pruneCompileRuns } = context;
28
+ const { config, db, collaboration, projectMutations, compileCoordinator, metrics, pruneCompileRuns } = context;
17
29
  app.get("/api/projects/:id/compile/latest", async (request, reply) => {
18
30
  const user = requireUser(request, reply, db);
19
31
  if (!user)
@@ -70,29 +82,74 @@ export function registerCompileRoutes(app, context) {
70
82
  if (!mainFile)
71
83
  return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
72
84
  const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
73
- if (!query.path)
74
- return { mainFile, runId: published?.runId ?? null, artifacts: published ? listCompileArtifacts(published.output) : [] };
85
+ if (!query.path) {
86
+ if (!published)
87
+ return { mainFile, runId: null, artifacts: [] };
88
+ try {
89
+ return { mainFile, runId: published.runId, artifacts: listCompileArtifacts(published.output) };
90
+ }
91
+ catch (error) {
92
+ // Cleanup can remove a retained run after the manifest has been read.
93
+ // Treat that narrow race as an empty artifact list instead of leaking
94
+ // an internal ENOENT/ENOTDIR as a 500 response.
95
+ if (isMissingFileError(error))
96
+ return { mainFile, runId: null, artifacts: [] };
97
+ throw error;
98
+ }
99
+ }
75
100
  if (!published)
76
101
  return apiError(reply, 404, "COMPILE_ARTIFACTS_NOT_FOUND", "尚无可用的编译产物");
77
102
  const relative = safeRelativePath(query.path);
78
103
  const absolute = path.join(published.output, relative);
79
104
  const outputDirectory = path.resolve(published.output);
80
105
  const resolved = path.resolve(absolute);
81
- if (!resolved.startsWith(`${outputDirectory}${path.sep}`)
82
- || !fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
106
+ if (!resolved.startsWith(`${outputDirectory}${path.sep}`)) {
83
107
  return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
84
108
  }
85
- const stat = fs.statSync(resolved);
109
+ let stat;
110
+ try {
111
+ stat = fs.statSync(resolved);
112
+ if (!stat.isFile())
113
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
114
+ }
115
+ catch (error) {
116
+ if (isMissingFileError(error))
117
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
118
+ throw error;
119
+ }
86
120
  if (query.download === "1") {
121
+ const temporaryDirectory = path.join(config.dataDir, "tmp");
122
+ const temporaryFile = path.join(temporaryDirectory, `artifact-${id}-${randomUUID()}`);
123
+ await fs.promises.mkdir(temporaryDirectory, { recursive: true, mode: 0o700 });
124
+ try {
125
+ await fs.promises.copyFile(resolved, temporaryFile);
126
+ }
127
+ catch (error) {
128
+ await fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined);
129
+ if (isMissingFileError(error))
130
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
131
+ throw error;
132
+ }
87
133
  reply.header("Content-Type", "application/octet-stream");
88
134
  reply.header("Content-Disposition", contentDisposition(path.basename(relative), "attachment"));
89
135
  reply.header("Content-Length", stat.size);
90
- return reply.send(fs.createReadStream(resolved));
136
+ const stream = fs.createReadStream(temporaryFile);
137
+ const cleanup = () => { void fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined); };
138
+ stream.once("close", cleanup);
139
+ stream.once("error", cleanup);
140
+ return reply.send(stream);
91
141
  }
92
142
  if (stat.size > 2 * 1024 * 1024 || !isTextCompileArtifact(relative)) {
93
143
  return apiError(reply, 415, "ARTIFACT_PREVIEW_UNSUPPORTED", "该产物不能作为文本预览,请下载后查看");
94
144
  }
95
- return { path: relative, content: fs.readFileSync(resolved, "utf8") };
145
+ try {
146
+ return { path: relative, content: fs.readFileSync(resolved, "utf8") };
147
+ }
148
+ catch (error) {
149
+ if (isMissingFileError(error))
150
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
151
+ throw error;
152
+ }
96
153
  });
97
154
  app.post("/api/projects/:id/compile/clean", async (request, reply) => {
98
155
  const user = requireUser(request, reply, db);
@@ -109,37 +166,49 @@ export function registerCompileRoutes(app, context) {
109
166
  if (body.mode !== "cache" && body.mode !== "artifacts") {
110
167
  return apiError(reply, 400, "REQUEST_INVALID", "清理模式无效");
111
168
  }
112
- const activeRun = db.prepare(`SELECT 1 AS active FROM compile_runs
113
- WHERE project_id = ? AND main_file = ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile);
114
- if (activeRun)
115
- return apiError(reply, 409, "COMPILE_CLEAN_BUSY", "当前主文档正在编译,请稍后再清理");
116
- const requestedBy = { id: user.id, username: user.username, name: user.display_name };
117
- if (body.mode === "cache") {
118
- cleanCompileCache(config, id, mainFile);
169
+ return await projectMutations.runCompileExclusive(id, () => {
170
+ const activeRun = db.prepare(`SELECT 1 AS active FROM compile_runs
171
+ WHERE project_id = ? AND main_file = ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile);
172
+ if (activeRun)
173
+ return apiError(reply, 409, "COMPILE_CLEAN_BUSY", "当前主文档正在编译,请稍后再清理");
174
+ const requestedBy = { id: user.id, username: user.username, name: user.display_name };
175
+ if (body.mode === "cache") {
176
+ cleanCompileCache(config, id, mainFile);
177
+ collaboration.signalCompileState(id, {
178
+ mainFile,
179
+ runId: `clean-${randomUUID()}`,
180
+ status: "cleaned",
181
+ cleanMode: "cache",
182
+ requestedBy,
183
+ updatedAt: now()
184
+ });
185
+ return { ok: true, mode: "cache", mainFile, retainedPdf: true };
186
+ }
187
+ const currentProject = accessibleProject(db, id, user);
188
+ if (!currentProject || !canEdit(currentProject))
189
+ return apiError(reply, 403, "COMPILE_FORBIDDEN", "没有清理编译产物的权限");
190
+ const runs = db.prepare(`SELECT id FROM compile_runs
191
+ WHERE project_id = ? AND main_file = ? AND status NOT IN ('queued', 'running')`).all(id, mainFile);
192
+ cleanCompileArtifacts(config, id, mainFile, currentProject.main_file, runs.map((run) => run.id));
193
+ db.prepare("DELETE FROM compile_runs WHERE project_id = ? AND main_file = ? AND status NOT IN ('queued', 'running')").run(id, mainFile);
194
+ pruneCompileRuns(id);
119
195
  collaboration.signalCompileState(id, {
120
196
  mainFile,
121
197
  runId: `clean-${randomUUID()}`,
122
198
  status: "cleaned",
123
- cleanMode: "cache",
199
+ cleanMode: "artifacts",
124
200
  requestedBy,
125
201
  updatedAt: now()
126
202
  });
127
- return { ok: true, mode: "cache", mainFile, retainedPdf: true };
128
- }
129
- const runs = db.prepare(`SELECT id FROM compile_runs
130
- WHERE project_id = ? AND main_file = ? AND status NOT IN ('queued', 'running')`).all(id, mainFile);
131
- cleanCompileArtifacts(config, id, mainFile, project.main_file, runs.map((run) => run.id));
132
- db.prepare("DELETE FROM compile_runs WHERE project_id = ? AND main_file = ? AND status NOT IN ('queued', 'running')").run(id, mainFile);
133
- pruneCompileRuns(id);
134
- collaboration.signalCompileState(id, {
135
- mainFile,
136
- runId: `clean-${randomUUID()}`,
137
- status: "cleaned",
138
- cleanMode: "artifacts",
139
- requestedBy,
140
- updatedAt: now()
141
- });
142
- return { ok: true, mode: "artifacts", mainFile, retainedPdf: false };
203
+ return { ok: true, mode: "artifacts", mainFile, retainedPdf: false };
204
+ }, { preflight: () => {
205
+ const current = accessibleProject(db, id, user);
206
+ if (!current || !canEdit(current))
207
+ throw Object.assign(new Error("没有清理编译产物的权限"), { statusCode: 403, code: "COMPILE_FORBIDDEN" });
208
+ const selected = compileMainFile(config, id, current.main_file, body.mainFile);
209
+ if (!selected || selected !== mainFile)
210
+ throw Object.assign(new Error("所选文件不是有效的 LaTeX 主文档"), { statusCode: 400, code: "MAIN_DOCUMENT_INVALID" });
211
+ } });
143
212
  });
144
213
  app.get("/api/projects/:id/sync/pdf", async (request, reply) => {
145
214
  const user = requireUser(request, reply, db);
@@ -159,13 +228,18 @@ export function registerCompileRoutes(app, context) {
159
228
  if (!Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
160
229
  return apiError(reply, 400, "SYNC_SOURCE_INVALID", "源码位置无效");
161
230
  }
162
- if (!fs.existsSync(resolveSourcePath(config, id, sourcePath))) {
163
- return apiError(reply, 404, "SOURCE_FILE_NOT_FOUND", "源码文件不存在");
164
- }
165
- const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
166
- if (!artifacts)
167
- return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
168
- return await sourceToPdf(artifacts.source, artifacts.pdf, sourcePath, line, column);
231
+ return await projectMutations.runConsistentRead(id, async () => {
232
+ if (!fs.existsSync(resolveSourcePath(config, id, sourcePath))) {
233
+ return apiError(reply, 404, "SOURCE_FILE_NOT_FOUND", "源码文件不存在");
234
+ }
235
+ const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
236
+ if (!artifacts)
237
+ return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
238
+ return await sourceToPdf(artifacts.source, artifacts.pdf, sourcePath, line, column);
239
+ }, { preflight: () => {
240
+ if (!accessibleProject(db, id, user))
241
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
242
+ } });
169
243
  });
170
244
  app.get("/api/projects/:id/sync/source", async (request, reply) => {
171
245
  const user = requireUser(request, reply, db);
@@ -185,16 +259,21 @@ export function registerCompileRoutes(app, context) {
185
259
  if (!Number.isInteger(page) || page < 1 || !Number.isFinite(x) || x < 0 || !Number.isFinite(y) || y < 0) {
186
260
  return apiError(reply, 400, "SYNC_PDF_INVALID", "PDF 位置无效");
187
261
  }
188
- const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
189
- if (!artifacts)
190
- return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
191
- const location = await pdfToSource(artifacts.source, artifacts.pdf, page, x, y);
192
- const sourceDirectory = path.resolve(artifacts.source);
193
- const relative = path.relative(sourceDirectory, path.resolve(location.input));
194
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
195
- return apiError(reply, 400, "SYNCTEX_EXTERNAL_PATH", "SyncTeX 返回了项目外部的源码路径");
196
- }
197
- return { path: safeRelativePath(relative.replaceAll(path.sep, "/")), line: location.line, column: location.column };
262
+ return await projectMutations.runConsistentRead(id, async () => {
263
+ const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
264
+ if (!artifacts)
265
+ return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
266
+ const location = await pdfToSource(artifacts.source, artifacts.pdf, page, x, y);
267
+ const sourceDirectory = path.resolve(artifacts.source);
268
+ const relative = path.relative(sourceDirectory, path.resolve(location.input));
269
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
270
+ return apiError(reply, 400, "SYNCTEX_EXTERNAL_PATH", "SyncTeX 返回了项目外部的源码路径");
271
+ }
272
+ return { path: safeRelativePath(relative.replaceAll(path.sep, "/")), line: location.line, column: location.column };
273
+ }, { preflight: () => {
274
+ if (!accessibleProject(db, id, user))
275
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
276
+ } });
198
277
  });
199
278
  app.post("/api/projects/:id/compile", async (request, reply) => {
200
279
  const requestStartedAt = performance.now();
@@ -202,116 +281,358 @@ export function registerCompileRoutes(app, context) {
202
281
  if (!user)
203
282
  return;
204
283
  const { id } = request.params;
205
- const project = accessibleProject(db, id, user);
206
- if (!project || !canEdit(project))
284
+ const initialProject = accessibleProject(db, id, user);
285
+ if (!initialProject || !canEdit(initialProject))
207
286
  return apiError(reply, 403, "COMPILE_FORBIDDEN", "没有编译权限");
208
287
  const body = (request.body ?? {});
209
- const mainFile = compileMainFile(config, id, project.main_file, body.mainFile);
210
- if (!mainFile)
288
+ const requestedMainFile = typeof body.mainFile === "string" && body.mainFile ? body.mainFile : null;
289
+ const initialMainFile = compileMainFile(config, id, initialProject.main_file, body.mainFile);
290
+ if (!initialMainFile)
211
291
  return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
212
- collaboration.flushProject(id);
213
292
  const runId = randomUUID();
214
- const snapshotStartedAt = performance.now();
215
- const snapshot = await captureCompileSnapshot(config, id, runId, {
216
- mainFile,
217
- engine: project.engine,
218
- latexmkrc: project.latexmkrc,
219
- extraArgs: config.extraArgs
220
- });
221
- const snapshotMs = performance.now() - snapshotStartedAt;
222
- metrics.record("compile.snapshot", snapshotMs);
223
- const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
224
- const publishedRun = published ? db.prepare(`SELECT id, status, log, finished_at
225
- FROM compile_runs WHERE id = ? AND project_id = ?`).get(published.runId, id) : undefined;
226
- const latestRun = db.prepare(`SELECT id, status FROM compile_runs
227
- WHERE project_id = ? AND main_file = ? ORDER BY created_at DESC LIMIT 1`).get(id, mainFile);
228
- const activeRun = db.prepare(`SELECT 1 AS active FROM compile_runs
229
- WHERE project_id = ? AND main_file = ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile);
230
- if (!activeRun && published && publishedRun?.status === "succeeded"
231
- && latestRun?.id === publishedRun.id && snapshot.revision === published.revision
232
- && hasCompileCache(config, id, mainFile)) {
233
- discardCompileSnapshot(snapshot);
234
- const requestMs = performance.now() - requestStartedAt;
235
- metrics.record("compile.request", requestMs);
236
- reply.header("Server-Timing", `snapshot;dur=${timingDuration(snapshotMs)}, total;dur=${timingDuration(requestMs)}`);
237
- return {
238
- runId: published.runId,
239
- ok: true,
240
- skipped: true,
241
- log: publishedRun.log ?? "",
242
- diagnostics: parseCompileDiagnostics(publishedRun.log ?? "", "succeeded"),
243
- mainFile,
244
- pdfUrl: compilePdfUrl(id, mainFile, published.runId),
245
- pdfCompiledAt: publishedRun.finished_at,
246
- timings: { snapshotMs, requestMs }
293
+ // Admission must stay cheap. In particular, do not call
294
+ // captureCompileSnapshot here: several collaborators can reach this
295
+ // endpoint at the same time, and the coordinator needs to coalesce them
296
+ // before any project files are copied or hashed.
297
+ const admission = await projectMutations.runSnapshot(id, () => {
298
+ const currentProject = accessibleProject(db, id, user);
299
+ if (!currentProject || !canEdit(currentProject))
300
+ throw new Error("Project access changed while waiting for a source snapshot");
301
+ const currentMainFile = compileMainFile(config, id, currentProject.main_file, body.mainFile);
302
+ if (!currentMainFile)
303
+ throw new Error("Selected main document is no longer available");
304
+ if (!requestedMainFile && currentMainFile !== initialMainFile) {
305
+ throw mainDocumentChangedError();
306
+ }
307
+ // flushProject() is also the final durability barrier for collaborative
308
+ // edits. Its revision is stable for a no-op flush, so concurrent
309
+ // admissions observe the same generation without reading file contents.
310
+ const revisionBeforeFlush = collaboration.currentRevision(id);
311
+ const receipt = collaboration.hasPendingChanges(id) ? projectMutations.flushProject(id) : null;
312
+ const refreshedProject = accessibleProject(db, id, user);
313
+ if (!refreshedProject || !canEdit(refreshedProject)) {
314
+ throw new Error("Project access changed while preparing a compile request");
315
+ }
316
+ const generationRevision = receipt?.revision ?? revisionBeforeFlush;
317
+ const admissionResult = {
318
+ project: refreshedProject,
319
+ mainFile: currentMainFile,
320
+ generation: compileRequestGeneration(refreshedProject, generationRevision, collaboration.currentGeneration(id), config.extraArgs)
247
321
  };
248
- }
249
- db.prepare("INSERT INTO compile_runs (id, project_id, requested_by, main_file, status, created_at) VALUES (?, ?, ?, ?, 'queued', ?)")
250
- .run(runId, id, user.id, mainFile, now());
322
+ // Insert the queued probe while the project lock is still held. A
323
+ // concurrent clean request must observe this row before the compile
324
+ // coordinator starts copying a snapshot.
325
+ db.prepare("INSERT INTO compile_runs (id, project_id, requested_by, main_file, status, created_at) VALUES (?, ?, ?, ?, 'queued', ?)")
326
+ .run(runId, id, user.id, currentMainFile, now());
327
+ return admissionResult;
328
+ });
329
+ let project = admission.project;
330
+ let mainFile = admission.mainFile;
251
331
  const requestedBy = { id: user.id, username: user.username, name: user.display_name };
252
332
  let phase = "queued";
253
- const broadcast = () => collaboration.signalCompileState(id, { mainFile, runId, status: phase, requestedBy, updatedAt: now() });
333
+ let snapshotStale = false;
334
+ let snapshotGeneration = null;
335
+ const refreshSnapshotStale = () => {
336
+ if (!snapshotGeneration)
337
+ return;
338
+ if (collaboration.hasPendingChanges(id)) {
339
+ snapshotStale = true;
340
+ return;
341
+ }
342
+ const currentProject = accessibleProject(db, id, user);
343
+ if (!currentProject)
344
+ return;
345
+ const currentGeneration = compileRequestGeneration(currentProject, collaboration.currentRevision(id), collaboration.currentGeneration(id), config.extraArgs);
346
+ if (currentGeneration !== snapshotGeneration)
347
+ snapshotStale = true;
348
+ };
349
+ const broadcast = () => collaboration.signalCompileState(id, {
350
+ mainFile, runId, status: phase, requestedBy, updatedAt: now(),
351
+ ...(phase === "succeeded" && snapshotStale ? { stale: true } : {})
352
+ });
254
353
  const result = await compileCoordinator.request({
255
354
  projectId: id,
256
355
  target: mainFile,
257
356
  runId,
258
- revision: snapshot.revision,
357
+ // This key is computed before snapshot creation. The content digest
358
+ // returned by captureCompileSnapshot is only the result revision.
359
+ generation: admission.generation,
360
+ revision: admission.generation,
259
361
  onQueued: broadcast,
260
362
  onSelected: broadcast,
261
363
  onDiscarded: () => {
262
364
  db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
263
- discardCompileSnapshot(snapshot);
264
365
  },
265
- execute: async () => {
266
- phase = "running";
267
- db.prepare("UPDATE compile_runs SET status = 'running' WHERE id = ?").run(runId);
268
- broadcast();
269
- let compiled;
366
+ // The compile reservation protects the shared compiler cache and
367
+ // prevents Git/cleanup/deletion from replacing the project while
368
+ // latexmk is running, but it deliberately does not occupy the ordinary
369
+ // project queue for the duration of the subprocess.
370
+ execute: async () => projectMutations.runCompile(id, async () => {
371
+ let snapshot = null;
372
+ let snapshotMs = 0;
373
+ const snapshotStartedAt = performance.now();
270
374
  try {
271
- compiled = await compileProject(config, snapshot, mainFile, project.engine, project.latexmkrc);
272
- if (compiled.ok && compiled.pdfPath) {
273
- if (!db.prepare("SELECT 1 FROM projects WHERE id = ?").get(id))
274
- throw new Error("项目已被删除");
275
- const publishStartedAt = performance.now();
276
- publishCompileArtifacts(config, id, snapshot, compiled);
277
- if (compiled.timings) {
278
- compiled.timings.publishMs = performance.now() - publishStartedAt;
279
- compiled.timings.totalMs += compiled.timings.publishMs;
375
+ // Refresh the cheap generation after waiting for any queued source
376
+ // operation. This makes the no-op check safe when a Git/HTTP write
377
+ // completed after admission but before this compile got a worker.
378
+ const refreshed = await projectMutations.runSerialized(id, () => {
379
+ const currentProject = accessibleProject(db, id, user);
380
+ if (!currentProject || !canEdit(currentProject)) {
381
+ throw new Error("Project access changed while waiting for a source snapshot");
280
382
  }
383
+ const currentMainFile = compileMainFile(config, id, currentProject.main_file, body.mainFile);
384
+ if (!currentMainFile)
385
+ throw new Error("Selected main document is no longer available");
386
+ if (!requestedMainFile && currentMainFile !== mainFile)
387
+ throw mainDocumentChangedError();
388
+ project = currentProject;
389
+ mainFile = currentMainFile;
390
+ return {
391
+ generation: compileRequestGeneration(currentProject, collaboration.currentRevision(id), collaboration.currentGeneration(id), config.extraArgs)
392
+ };
393
+ });
394
+ const currentGeneration = refreshed.generation;
395
+ const publishedBeforeSnapshot = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
396
+ const publishedRunBeforeSnapshot = publishedBeforeSnapshot ? db.prepare(`SELECT id, status, log, finished_at
397
+ FROM compile_runs WHERE id = ? AND project_id = ?`).get(publishedBeforeSnapshot.runId, id) : undefined;
398
+ const latestRunBeforeSnapshot = db.prepare(`SELECT id, status FROM compile_runs
399
+ WHERE project_id = ? AND main_file = ? AND id <> ? ORDER BY created_at DESC LIMIT 1`).get(id, mainFile, runId);
400
+ const activeRunBeforeSnapshot = db.prepare(`SELECT 1 AS active FROM compile_runs
401
+ WHERE project_id = ? AND main_file = ? AND id <> ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile, runId);
402
+ if (!activeRunBeforeSnapshot && publishedBeforeSnapshot?.generation === currentGeneration
403
+ && publishedRunBeforeSnapshot?.status === "succeeded"
404
+ && latestRunBeforeSnapshot?.id === publishedRunBeforeSnapshot.id
405
+ && hasCompileCache(config, id, mainFile)) {
406
+ db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
407
+ phase = "succeeded";
408
+ broadcast();
409
+ return {
410
+ runId: publishedBeforeSnapshot.runId,
411
+ ok: true,
412
+ skipped: true,
413
+ log: publishedRunBeforeSnapshot.log ?? "",
414
+ diagnostics: parseCompileDiagnostics(publishedRunBeforeSnapshot.log ?? "", "succeeded"),
415
+ pdfPath: null,
416
+ synctexPath: publishedBeforeSnapshot.synctex,
417
+ revision: publishedBeforeSnapshot.revision,
418
+ snapshotMs: 0
419
+ };
281
420
  }
282
- else {
421
+ const captured = await projectMutations.runSerialized(id, async () => {
422
+ const currentProject = accessibleProject(db, id, user);
423
+ if (!currentProject || !canEdit(currentProject)) {
424
+ throw new Error("Project access changed while waiting for a source snapshot");
425
+ }
426
+ const currentMainFile = compileMainFile(config, id, currentProject.main_file, body.mainFile);
427
+ if (!currentMainFile)
428
+ throw new Error("Selected main document is no longer available");
429
+ if (!requestedMainFile && currentMainFile !== mainFile)
430
+ throw mainDocumentChangedError();
431
+ project = currentProject;
432
+ mainFile = currentMainFile;
433
+ // Capture only after all exclusive source-tree operations queued
434
+ // before this request have completed. A collaboration barrier
435
+ // keeps autosave and explicit flushes from changing the source
436
+ // directory while the asynchronous copy is in progress.
437
+ for (let attempt = 0; attempt < MAX_SNAPSHOT_ATTEMPTS; attempt += 1) {
438
+ const revisionBeforeFlush = collaboration.currentRevision(id);
439
+ const receipt = collaboration.hasPendingChanges(id) ? projectMutations.flushProject(id) : null;
440
+ const expectedRevision = receipt?.revision ?? revisionBeforeFlush;
441
+ const generationKey = compileRequestGeneration(project, expectedRevision, collaboration.currentGeneration(id), config.extraArgs);
442
+ let candidate = null;
443
+ let captureError = null;
444
+ let releaseError = null;
445
+ let afterBarrierRevision = null;
446
+ collaboration.beginSnapshotBarrier(id);
447
+ try {
448
+ candidate = await captureCompileSnapshot(config, id, runId, {
449
+ mainFile,
450
+ engine: project.engine,
451
+ latexmkrc: project.latexmkrc,
452
+ extraArgs: config.extraArgs,
453
+ generation: generationKey
454
+ });
455
+ }
456
+ catch (error) {
457
+ captureError = error;
458
+ }
459
+ try {
460
+ const afterBarrier = collaboration.endSnapshotBarrier(id);
461
+ afterBarrierRevision = afterBarrier?.revision ?? collaboration.currentRevision(id);
462
+ }
463
+ catch (error) {
464
+ releaseError = error;
465
+ }
466
+ if (captureError) {
467
+ if (candidate)
468
+ discardCompileSnapshot(candidate);
469
+ throw captureError;
470
+ }
471
+ if (releaseError) {
472
+ if (candidate)
473
+ discardCompileSnapshot(candidate);
474
+ throw releaseError;
475
+ }
476
+ if (!candidate)
477
+ throw new Error("Unable to create a compile snapshot");
478
+ // The barrier makes the candidate internally consistent. Edits
479
+ // that arrive while files are being copied stay in Yjs memory
480
+ // and are flushed only after the barrier is released; they do
481
+ // not make the candidate a mixed or unsafe snapshot. Keep the
482
+ // snapshot and surface that it predates those newer edits.
483
+ const stable = collaboration.isStable(id, afterBarrierRevision);
484
+ if (stable) {
485
+ snapshotStale = afterBarrierRevision !== expectedRevision;
486
+ snapshotGeneration = candidate.generation ?? null;
487
+ return candidate;
488
+ }
489
+ discardCompileSnapshot(candidate);
490
+ }
491
+ throw new CompileSnapshotBusyError();
492
+ }, { flush: false });
493
+ snapshot = captured;
494
+ snapshotMs = performance.now() - snapshotStartedAt;
495
+ metrics.record("compile.snapshot", snapshotMs);
496
+ const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
497
+ const publishedRun = published ? db.prepare(`SELECT id, status, log, finished_at
498
+ FROM compile_runs WHERE id = ? AND project_id = ?`).get(published.runId, id) : undefined;
499
+ // The current queued row is intentionally excluded. It is a request
500
+ // probe, not evidence that the last successful compile is stale.
501
+ const latestRun = db.prepare(`SELECT id, status FROM compile_runs
502
+ WHERE project_id = ? AND main_file = ? AND id <> ? ORDER BY created_at DESC LIMIT 1`).get(id, mainFile, runId);
503
+ const activeRun = db.prepare(`SELECT 1 AS active FROM compile_runs
504
+ WHERE project_id = ? AND main_file = ? AND id <> ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile, runId);
505
+ if (!activeRun && published && publishedRun?.status === "succeeded"
506
+ && latestRun?.id === publishedRun.id && snapshot.revision === published.revision
507
+ && hasCompileCache(config, id, mainFile)) {
283
508
  discardCompileSnapshot(snapshot);
509
+ snapshot = null;
510
+ db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
511
+ phase = "succeeded";
512
+ broadcast();
513
+ return {
514
+ runId: published.runId,
515
+ ok: true,
516
+ skipped: true,
517
+ stale: snapshotStale,
518
+ log: publishedRun.log ?? "",
519
+ diagnostics: parseCompileDiagnostics(publishedRun.log ?? "", "succeeded"),
520
+ pdfPath: null,
521
+ synctexPath: published.synctex,
522
+ revision: published.revision,
523
+ snapshotMs
524
+ };
284
525
  }
526
+ phase = "running";
527
+ db.prepare("UPDATE compile_runs SET status = 'running' WHERE id = ?").run(runId);
528
+ broadcast();
529
+ let compiled;
530
+ try {
531
+ compiled = await compileProject(config, snapshot, mainFile, project.engine, project.latexmkrc);
532
+ refreshSnapshotStale();
533
+ if (compiled.ok && compiled.pdfPath) {
534
+ const publishStartedAt = performance.now();
535
+ // Publishing is short and atomic, but still goes through the
536
+ // ordinary project queue so it cannot overlap a source-tree
537
+ // archive/read or another output mutation. The long latexmk
538
+ // process above remains outside that queue.
539
+ await projectMutations.runSerialized(id, () => {
540
+ if (!db.prepare("SELECT 1 FROM projects WHERE id = ?").get(id))
541
+ throw new Error("项目已被删除");
542
+ publishCompileArtifacts(config, id, snapshot, compiled);
543
+ }, { flush: false });
544
+ refreshSnapshotStale();
545
+ if (compiled.timings) {
546
+ compiled.timings.publishMs = performance.now() - publishStartedAt;
547
+ compiled.timings.totalMs += compiled.timings.publishMs;
548
+ }
549
+ }
550
+ else {
551
+ discardCompileSnapshot(snapshot);
552
+ snapshot = null;
553
+ }
554
+ }
555
+ catch (error) {
556
+ if (snapshot)
557
+ discardCompileSnapshot(snapshot);
558
+ snapshot = null;
559
+ compiled = {
560
+ ok: false,
561
+ log: error instanceof Error ? error.message : String(error),
562
+ diagnostics: parseCompileDiagnostics(error instanceof Error ? error.message : String(error), "failed"),
563
+ pdfPath: null,
564
+ synctexPath: null
565
+ };
566
+ }
567
+ phase = compiled.ok ? "succeeded" : "failed";
568
+ db.prepare("UPDATE compile_runs SET status = ?, log = ?, finished_at = ? WHERE id = ?")
569
+ .run(phase, compiled.log, now(), runId);
570
+ broadcast();
571
+ return { ...compiled, runId, revision: snapshot?.revision ?? admission.generation, stale: snapshotStale, snapshotMs };
285
572
  }
286
573
  catch (error) {
287
- discardCompileSnapshot(snapshot);
288
- compiled = {
574
+ if (snapshot)
575
+ discardCompileSnapshot(snapshot);
576
+ if (error instanceof CompileSnapshotBusyError) {
577
+ snapshotMs = performance.now() - snapshotStartedAt;
578
+ metrics.record("compile.snapshot", snapshotMs);
579
+ phase = "failed";
580
+ db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
581
+ broadcast();
582
+ return {
583
+ ok: false,
584
+ retryable: true,
585
+ errorCode: error.code,
586
+ log: error.message,
587
+ diagnostics: { warnings: [], errors: [] },
588
+ pdfPath: null,
589
+ synctexPath: null,
590
+ runId,
591
+ revision: admission.generation,
592
+ snapshotMs
593
+ };
594
+ }
595
+ const log = error instanceof Error ? error.message : String(error);
596
+ phase = "failed";
597
+ db.prepare("UPDATE compile_runs SET status = 'failed', log = ?, finished_at = ? WHERE id = ?")
598
+ .run(log, now(), runId);
599
+ broadcast();
600
+ return {
289
601
  ok: false,
290
- log: error instanceof Error ? error.message : String(error),
291
- diagnostics: parseCompileDiagnostics(error instanceof Error ? error.message : String(error), "failed"),
602
+ log,
603
+ diagnostics: parseCompileDiagnostics(log, "failed"),
292
604
  pdfPath: null,
293
- synctexPath: null
605
+ synctexPath: null,
606
+ runId,
607
+ revision: admission.generation,
608
+ snapshotMs
294
609
  };
295
610
  }
296
- phase = compiled.ok ? "succeeded" : "failed";
297
- db.prepare("UPDATE compile_runs SET status = ?, log = ?, finished_at = ? WHERE id = ?")
298
- .run(phase, compiled.log, now(), runId);
299
- broadcast();
300
- return { ...compiled, runId, revision: snapshot.revision };
301
- }
611
+ }),
302
612
  });
303
613
  const completed = db.prepare("SELECT finished_at FROM compile_runs WHERE id = ?").get(result.runId);
304
614
  const requestMs = performance.now() - requestStartedAt;
305
615
  metrics.record("compile.request", requestMs);
616
+ if (result.retryable) {
617
+ await projectMutations.runSerialized(id, () => { pruneCompileRuns(id); }, { flush: false });
618
+ reply.header("Retry-After", String(SNAPSHOT_RETRY_AFTER_SECONDS));
619
+ reply.header("Server-Timing", [
620
+ `snapshot;dur=${timingDuration(result.snapshotMs ?? 0)}`,
621
+ `total;dur=${timingDuration(requestMs)}`
622
+ ].join(", "));
623
+ return apiError(reply, 409, result.errorCode ?? "COMPILE_SNAPSHOT_BUSY", result.log, { retryable: true, retryAfterSeconds: SNAPSHOT_RETRY_AFTER_SECONDS });
624
+ }
306
625
  if (result.timings) {
307
626
  metrics.record("compile.cacheSync", result.timings.cacheSyncMs);
308
627
  metrics.record("compile.latexmk", result.timings.latexmkMs);
309
628
  metrics.record("compile.artifactCopy", result.timings.artifactCopyMs);
310
629
  }
311
- pruneCompileRuns(id);
312
- const timings = result.timings ? { snapshotMs, ...result.timings, requestMs } : { snapshotMs, requestMs };
630
+ await projectMutations.runSerialized(id, () => { pruneCompileRuns(id); }, { flush: false });
631
+ const timings = result.timings
632
+ ? { snapshotMs: result.snapshotMs ?? 0, ...result.timings, requestMs }
633
+ : { snapshotMs: result.snapshotMs ?? 0, requestMs };
313
634
  reply.header("Server-Timing", [
314
- `snapshot;dur=${timingDuration(snapshotMs)}`,
635
+ `snapshot;dur=${timingDuration(result.snapshotMs ?? 0)}`,
315
636
  result.timings ? `cache;dur=${timingDuration(result.timings.cacheSyncMs)}` : "",
316
637
  result.timings ? `latexmk;dur=${timingDuration(result.timings.latexmkMs)}` : "",
317
638
  result.timings ? `artifacts;dur=${timingDuration(result.timings.artifactCopyMs)}` : "",
@@ -319,12 +640,17 @@ export function registerCompileRoutes(app, context) {
319
640
  `total;dur=${timingDuration(requestMs)}`
320
641
  ].filter(Boolean).join(", "));
321
642
  return {
322
- mainFile, runId: result.runId, ok: result.ok, skipped: false, log: result.log, diagnostics: result.diagnostics,
643
+ mainFile, runId: result.runId, ok: result.ok, skipped: result.skipped === true, log: result.log, diagnostics: result.diagnostics,
323
644
  pdfUrl: result.ok ? compilePdfUrl(id, mainFile, result.runId) : null,
324
645
  pdfCompiledAt: result.ok ? completed?.finished_at ?? null : null,
325
646
  timings
326
647
  };
327
648
  });
649
+ // Published PDFs are retained, immutable artifacts. Keep this read outside
650
+ // projectMutations: runSerialized/runConsistentRead wait for a cold Yjs room
651
+ // to finish initializing, which would delay the first PDF paint after a
652
+ // server restart. The manifest and per-run bundle are swapped atomically,
653
+ // so a concurrent cleanup can safely result in a normal 404 instead.
328
654
  app.get("/api/projects/:id/pdf", async (request, reply) => {
329
655
  const user = requireUser(request, reply, db);
330
656
  if (!user)
@@ -359,10 +685,12 @@ export function registerCompileRoutes(app, context) {
359
685
  }
360
686
  }
361
687
  const pdf = artifact.path;
362
- const stat = fs.statSync(pdf);
688
+ const stat = regularFileStat(pdf);
689
+ if (!stat)
690
+ return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
363
691
  const etag = `"${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}"`;
364
692
  reply.header("Content-Type", "application/pdf");
365
- const targetSuffix = mainFile === project.main_file ? "" : `-${path.basename(mainFile, ".tex")}`;
693
+ const targetSuffix = mainFile === project.main_file ? "" : `-${texFileStem(mainFile)}`;
366
694
  const filename = downloading ? `${project.name}${targetSuffix}-${pdfDownloadTimestamp()}.pdf` : `${project.name}${targetSuffix}.pdf`;
367
695
  reply.header("Content-Disposition", contentDisposition(filename, downloading ? "attachment" : "inline"));
368
696
  // The run query identifies an immutable successful compile. Keep it in the
@@ -387,10 +715,59 @@ export function registerCompileRoutes(app, context) {
387
715
  if (range) {
388
716
  reply.header("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
389
717
  reply.header("Content-Length", range.end - range.start + 1);
390
- return reply.code(206).send(fs.createReadStream(pdf, range));
718
+ let stream;
719
+ try {
720
+ stream = await openReadStream(fs.createReadStream(pdf, range));
721
+ }
722
+ catch (error) {
723
+ if (isMissingFileError(error))
724
+ return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
725
+ throw error;
726
+ }
727
+ return reply.code(206).send(stream);
391
728
  }
392
729
  reply.header("Content-Length", stat.size);
393
- return reply.send(fs.createReadStream(pdf));
730
+ let stream;
731
+ try {
732
+ stream = await openReadStream(fs.createReadStream(pdf));
733
+ }
734
+ catch (error) {
735
+ if (isMissingFileError(error))
736
+ return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
737
+ throw error;
738
+ }
739
+ return reply.code(200).send(stream);
740
+ });
741
+ }
742
+ function regularFileStat(filePath) {
743
+ try {
744
+ const stat = fs.statSync(filePath);
745
+ return stat.isFile() ? stat : null;
746
+ }
747
+ catch {
748
+ return null;
749
+ }
750
+ }
751
+ function isMissingFileError(error) {
752
+ return typeof error === "object" && error !== null && "code" in error
753
+ && (error.code === "ENOENT" || error.code === "ENOTDIR");
754
+ }
755
+ async function openReadStream(stream) {
756
+ const opened = new Promise((resolve, reject) => {
757
+ stream.once("open", () => resolve());
758
+ stream.once("error", reject);
759
+ });
760
+ await opened;
761
+ return stream;
762
+ }
763
+ function compileRequestGeneration(project, persistedRevision, sourceGeneration, extraArgs) {
764
+ return JSON.stringify({
765
+ mainFile: project.main_file,
766
+ engine: project.engine,
767
+ latexmkrc: project.latexmkrc,
768
+ persistedRevision,
769
+ sourceGeneration,
770
+ extraArgs
394
771
  });
395
772
  }
396
773
  function pdfDownloadTimestamp(date = new Date()) {