parse-worker.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. /**
  2. * Parse Worker
  3. *
  4. * Runs tree-sitter parsing in a separate thread so the main thread
  5. * stays unblocked and the UI animation renders smoothly.
  6. */
  7. // Compile cache FIRST: the worker's boot cost is dominated by re-requiring
  8. // the extraction module graph; the persistent V8 cache (Node ≥22.8) makes
  9. // that a bytecode load instead of a recompile. Safe no-op when unavailable.
  10. try {
  11. // eslint-disable-next-line @typescript-eslint/no-require-imports
  12. (require('node:module') as { enableCompileCache?: () => void }).enableCompileCache?.();
  13. } catch { /* cache is best-effort */ }
  14. import { parentPort } from 'worker_threads';
  15. import { extractFromSource } from './tree-sitter';
  16. import { detectLanguage, loadGrammarsForLanguages, resetParser } from './grammars';
  17. import { tryKernelExtractRaw } from './kernel';
  18. import { getAllFrameworkResolvers, getApplicableFrameworks } from '../resolution/frameworks';
  19. import type { Language, ExtractionResult } from '../types';
  20. // Emscripten prints `Aborted()` (and a follow-up RuntimeError diag
  21. // line) directly to stderr when WASM aborts — before the JS catch
  22. // runs. Worker stderr is inherited by the parent, so each crash leaks
  23. // a noise line to the user's terminal even though the JS layer
  24. // already handles the failure cleanly. Filter these specific lines
  25. // out at the source. Real diagnostic output (anything we log
  26. // ourselves) goes through console.* / parentPort and is unaffected.
  27. //
  28. // Caveats deliberately accepted:
  29. // - Per-call match: each `write()` call is matched in isolation.
  30. // If Emscripten ever splits `Aborted(` across two write()s (it
  31. // doesn't today — synchronous abort prints the whole line at
  32. // once via libc puts) the first fragment would leak. Buffering
  33. // across calls would add complexity for a hypothetical case.
  34. // - Substring exactness: the prefix `Aborted(` is the literal
  35. // Emscripten signature. Any user code that legitimately writes
  36. // a stderr line starting with that prefix would also be filtered;
  37. // in practice no real diagnostic does.
  38. {
  39. const realWrite = process.stderr.write.bind(process.stderr);
  40. process.stderr.write = ((
  41. chunk: string | Uint8Array,
  42. encoding?: BufferEncoding | ((err?: Error | null) => void),
  43. cb?: (err?: Error | null) => void
  44. ): boolean => {
  45. const s = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8');
  46. if (
  47. s.startsWith('Aborted(') ||
  48. s.includes('Build with -sASSERTIONS for more info')
  49. ) {
  50. // Honour the Writable stream contract: callbacks must always
  51. // fire even when the write is suppressed, or upstream code
  52. // waiting on the drain signal would hang. Both overload forms
  53. // are handled (`(chunk, cb)` and `(chunk, encoding, cb)`).
  54. if (typeof encoding === 'function') encoding();
  55. else if (cb) cb();
  56. return true;
  57. }
  58. return realWrite(chunk as never, encoding as never, cb as never);
  59. }) as typeof process.stderr.write;
  60. }
  61. const PARSER_RESET_INTERVAL = 5000;
  62. const parseCounts = new Map<Language, number>();
  63. parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[]; language?: Language; grammarBuffers?: Record<string, Uint8Array> }) => {
  64. if (msg.type === 'load-grammars') {
  65. // Grammar WASM bytes pre-read by the main thread (when provided) make this
  66. // a memory load instead of a per-spawn disk read — see issue #1231.
  67. await loadGrammarsForLanguages(msg.languages!, msg.grammarBuffers);
  68. parentPort!.postMessage({ type: 'grammars-loaded' });
  69. } else if (msg.type === 'parse') {
  70. const { id, filePath, content, frameworkNames } = msg;
  71. // Worker-side parse clock: reported back with the result so the pool can
  72. // tell a genuinely slow parse from a result whose delivery was delayed by
  73. // a stalled main thread (issue #1231 false timeouts).
  74. const t0 = performance.now();
  75. try {
  76. // The main thread resolves the language (it holds the project's
  77. // codegraph.json extension overrides) and sends it; fall back to detection
  78. // for older callers / safety.
  79. const language = msg.language ?? detectLanguage(filePath!, content);
  80. // Kernel deferred-decode fast path: ship the file's tables as flat
  81. // buffers and decode at the STORE boundary, so the main thread never
  82. // materializes per-node objects (nor pays their structured-clone cost —
  83. // buffer clone is a flat memcpy). Only when no applicable framework has
  84. // an extract() hook: those merge extra nodes/refs into the DECODED
  85. // result inside extractFromSource, so such files keep the decoded path.
  86. let result: ExtractionResult | undefined;
  87. const frameworksNeedDecode =
  88. frameworkNames && frameworkNames.length > 0
  89. ? getApplicableFrameworks(
  90. getAllFrameworkResolvers().filter((r) => frameworkNames.includes(r.name)),
  91. language
  92. ).some((fw) => !!fw.extract)
  93. : false;
  94. if (!frameworksNeedDecode) {
  95. const raw = tryKernelExtractRaw(filePath!, content!, language);
  96. if (raw) {
  97. result = {
  98. nodes: [],
  99. edges: [],
  100. unresolvedReferences: [],
  101. errors: raw.errors,
  102. durationMs: 0,
  103. kernelBuffers: raw.buffers,
  104. kernelCounts: raw.counts,
  105. };
  106. }
  107. }
  108. result ??= extractFromSource(filePath!, content!, language, frameworkNames);
  109. // Periodic parser reset to reclaim WASM heap memory
  110. const count = (parseCounts.get(language) ?? 0) + 1;
  111. parseCounts.set(language, count);
  112. if (count % PARSER_RESET_INTERVAL === 0) {
  113. resetParser(language);
  114. }
  115. parentPort!.postMessage({ type: 'parse-result', id, result, parseMs: performance.now() - t0 });
  116. } catch (err) {
  117. const message = err instanceof Error ? err.message : String(err);
  118. // WASM memory errors leave the module in a corrupted state — all
  119. // subsequent parses would also fail (cascading failures). Crash the
  120. // worker so the main thread spawns a fresh one with a clean heap.
  121. if (message.includes('memory access out of bounds') || message.includes('out of memory')) {
  122. process.exit(1);
  123. }
  124. parentPort!.postMessage({
  125. type: 'parse-result',
  126. id,
  127. parseMs: performance.now() - t0,
  128. result: {
  129. nodes: [],
  130. edges: [],
  131. unresolvedReferences: [],
  132. errors: [{ message: `Parse worker error: ${message}`, filePath: filePath!, severity: 'error', code: 'parse_error' }],
  133. durationMs: 0,
  134. } satisfies ExtractionResult,
  135. });
  136. }
  137. } else if (msg.type === 'shutdown') {
  138. parentPort!.postMessage({ type: 'shutdown-ack' });
  139. }
  140. });