diff --git a/services/clsi/app/js/TypstRunner.js b/services/clsi/app/js/TypstRunner.js index 2193309621..863bf5c523 100644 --- a/services/clsi/app/js/TypstRunner.js +++ b/services/clsi/app/js/TypstRunner.js @@ -37,6 +37,13 @@ const FILE_ID_EXHAUSTION_RE = /ran out of file ids/i // Typst uses ~65 IDs per compile; 1000 compiles ≈ 65 000 — safely under 65 535. const MAX_COMPILES_BEFORE_RESTART = 1000 +// typst watch emits the "[HH:MM:SS] compiled with errors" status line FIRST, +// then the full diagnostic output (file:line:col, code snippets) AFTERWARDS. +// We buffer post-done lines and resolve after this delay if no new compile +// cycle starts sooner. 150 ms is well above the ~1 chunk latency for typst's +// diagnostic flush and imperceptible on top of a typical compile time. +const FLUSH_DELAY_MS = 150 + // --------------------------------------------------------------------------- // State (module-level, never exported) // --------------------------------------------------------------------------- @@ -53,7 +60,13 @@ const ProcessTable = {} // compilationCount total successful compile cycles on this watcher // restartPending flag to restart at the next runTypst call // accumulator incomplete trailing line from the last data chunk -// currentLines lines accumulated since the last compile-done event +// currentLines lines accumulated in the current phase (pre-done or +// post-done, see _onWatcherData for the two-phase logic) +// doneResult { preLines, compiledWithErrors } held between the +// COMPILE_DONE_RE line and the post-done diagnostic flush; +// null when not in post-done phase +// flushTimeout timeout handle that finalises doneResult when no next +// compile cycle starts within FLUSH_DELAY_MS // pendingResolvers Array<{resolve, reject, timeoutHandle}> // pendingResult compile result cached when typst finished before a // resolver was registered (race-condition safety net) @@ -142,6 +155,8 @@ async function _startWatcher(compileName, options) { restartPending: false, accumulator: '', currentLines: [], + doneResult: null, + flushTimeout: null, pendingResolvers: [], pendingResult: null, } @@ -187,6 +202,7 @@ async function _startWatcher(compileName, options) { function _killWatchEntry(compileName) { const entry = WatchTable[compileName] if (!entry) return + clearTimeout(entry.flushTimeout) delete WatchTable[compileName] try { _killedWatchPids.add(entry.proc.pid) @@ -211,9 +227,15 @@ function _onWatcherData(compileName, chunk) { for (const line of lines) { if (COMPILE_START_RE.test(line)) { - // New compile cycle — discard any output left over from a previous - // failed compile that didn't emit a "compiled with errors" footer. - entry.currentLines = [] + // A new compile cycle is starting. If we were in the post-done phase + // (collecting diagnostic lines that typst emits AFTER the status line), + // finalise the previous result now — all diagnostics have arrived. + if (entry.doneResult) { + _finalizeCompile(compileName) + } + // Start fresh for the new cycle. + entry.currentLines = [line] + continue } entry.currentLines.push(line) @@ -228,8 +250,6 @@ function _onWatcherData(compileName, chunk) { if (COMPILE_DONE_RE.test(line)) { entry.compilationCount++ - const stdout = entry.currentLines.join('\n') - entry.currentLines = [] if (entry.compilationCount >= MAX_COMPILES_BEFORE_RESTART) { logger.info( @@ -239,14 +259,48 @@ function _onWatcherData(compileName, chunk) { entry.restartPending = true } - _resolveAllPending(compileName, { - stdout, + // typst watch outputs the "[HH:MM:SS] compiled with errors" status + // line FIRST, then the full diagnostics (file:line:col, code snippets) + // AFTERWARDS. Enter post-done phase: keep accumulating into currentLines + // and flush after FLUSH_DELAY_MS (or immediately when the next compile + // cycle's COMPILE_START_RE arrives, whichever comes first). + entry.doneResult = { + preLines: entry.currentLines, compiledWithErrors: /compiled with errors/.test(line), - }) + } + entry.currentLines = [] + + clearTimeout(entry.flushTimeout) + entry.flushTimeout = setTimeout( + () => _finalizeCompile(compileName), + FLUSH_DELAY_MS + ) } } } +// Combines the pre-done lines (up to/including the status line) with any +// post-done diagnostic lines and resolves all pending waiters. +function _finalizeCompile(compileName) { + const entry = WatchTable[compileName] + if (!entry || !entry.doneResult) return + + clearTimeout(entry.flushTimeout) + entry.flushTimeout = null + + const { preLines, compiledWithErrors } = entry.doneResult + entry.doneResult = null + + // Merge: status line(s) first, then the post-done diagnostics. + const allLines = preLines.concat(entry.currentLines) + entry.currentLines = [] + + _resolveAllPending(compileName, { + stdout: allLines.join('\n'), + compiledWithErrors, + }) +} + // --------------------------------------------------------------------------- // Resolver helpers // ---------------------------------------------------------------------------