fix(ui): prevent shiki template-call OOM on backtick JS
Neutralize the catastrophic TextMate template-call lookahead when loading JS/TS grammars in the markdown Shiki worker, and terminate hung highlight requests after 5s so unbounded Oniguruma WASM matching cannot OOM the renderer (openchamber/openchamber#2587). Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
f47110c66f
commit
2b67be0a07
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { bundledLanguages, createHighlighter, type LanguageRegistration } from 'shiki';
|
||||
|
||||
import {
|
||||
hasCatastrophicTemplateCall,
|
||||
isTemplateCallLanguageId,
|
||||
sanitizeTemplateCallGrammar,
|
||||
TEMPLATE_CALL_LANGUAGE_IDS,
|
||||
} from './sanitizeTemplateCallGrammar';
|
||||
|
||||
type BundledLanguageModule = { default: LanguageRegistration[] };
|
||||
|
||||
const loadBundledGrammar = async (id: (typeof TEMPLATE_CALL_LANGUAGE_IDS)[number]): Promise<LanguageRegistration> => {
|
||||
const mod = (await bundledLanguages[id]()) as BundledLanguageModule;
|
||||
return mod.default[0];
|
||||
};
|
||||
|
||||
describe('sanitizeTemplateCallGrammar', () => {
|
||||
test('detects template-call on bundled JS/TS grammars', async () => {
|
||||
for (const id of TEMPLATE_CALL_LANGUAGE_IDS) {
|
||||
const grammar = await loadBundledGrammar(id);
|
||||
expect(isTemplateCallLanguageId(id)).toBe(true);
|
||||
expect(hasCatastrophicTemplateCall(grammar)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('clears template-call patterns without dropping the repository key', async () => {
|
||||
const grammar = await loadBundledGrammar('javascript');
|
||||
const patched = sanitizeTemplateCallGrammar(grammar);
|
||||
|
||||
expect(hasCatastrophicTemplateCall(patched)).toBe(false);
|
||||
expect(patched.repository?.['template-call']).toEqual({ patterns: [] });
|
||||
// Original left intact (structured clone / spread, not mutate-in-place).
|
||||
expect(hasCatastrophicTemplateCall(grammar)).toBe(true);
|
||||
});
|
||||
|
||||
test('is a no-op when template-call is already empty', () => {
|
||||
const grammar = {
|
||||
name: 'javascript',
|
||||
scopeName: 'source.js',
|
||||
patterns: [],
|
||||
repository: { 'template-call': { patterns: [] } },
|
||||
} satisfies LanguageRegistration;
|
||||
expect(sanitizeTemplateCallGrammar(grammar)).toBe(grammar);
|
||||
});
|
||||
|
||||
test('highlights template-literal fixtures within a tight budget after sanitize', async () => {
|
||||
const mod = (await bundledLanguages.javascript()) as BundledLanguageModule;
|
||||
const patched = mod.default.map((grammar) => sanitizeTemplateCallGrammar(grammar));
|
||||
|
||||
const highlighter = await createHighlighter({
|
||||
themes: ['github-dark'],
|
||||
langs: patched,
|
||||
});
|
||||
|
||||
// Representative content from openchamber/openchamber#2587, scaled to ~14KB.
|
||||
const fixture = `const snapshot = { source: \`\${session.source}\`, fetchedAt: \`\${Date.now()}\` };
|
||||
const label = \`Account \${index + 1}\`;
|
||||
function render(account) {
|
||||
return html\`<div class="\${account.cls}">\${account.name}</div>\`;
|
||||
}
|
||||
`.repeat(80);
|
||||
|
||||
expect(fixture.length).toBeGreaterThan(10_000);
|
||||
|
||||
const started = performance.now();
|
||||
const html = highlighter.codeToHtml(fixture, { lang: 'javascript', theme: 'github-dark' });
|
||||
const elapsedMs = performance.now() - started;
|
||||
highlighter.dispose();
|
||||
|
||||
expect(html.length).toBeGreaterThan(0);
|
||||
// Catastrophic backtracking hangs for seconds–minutes; healthy tokenize is well under 1s.
|
||||
expect(elapsedMs).toBeLessThan(2_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Neutralize the JavaScript/TypeScript TextMate `template-call` rule.
|
||||
*
|
||||
* Upstream grammars use a triple-nested `{()[]}` lookahead to detect tagged
|
||||
* templates with type arguments (`foo<T>\`...\``). On the Oniguruma WASM engine
|
||||
* shipped with Shiki — which does not expose `setRetryLimit` / match-stack
|
||||
* limits — that pattern can enter exponential backtracking on ordinary
|
||||
* backtick template literals, grow the WASM heap without bound, and OOM the
|
||||
* renderer (openchamber/openchamber#2587).
|
||||
*
|
||||
* Clearing `template-call` is safe: the plain `#template` rule still highlights
|
||||
* backticks and simple tagged templates. Only the rare `ident<TypeArgs>\`...\``
|
||||
* form loses its specialized type-argument coloring and falls through to
|
||||
* normal tokenization.
|
||||
*/
|
||||
|
||||
type GrammarRepository = Record<string, { patterns?: unknown[] } | undefined>;
|
||||
|
||||
export type TemplateCallGrammar = {
|
||||
name?: string;
|
||||
repository?: GrammarRepository;
|
||||
};
|
||||
|
||||
const TEMPLATE_CALL_KEY = 'template-call';
|
||||
|
||||
export const hasCatastrophicTemplateCall = (grammar: TemplateCallGrammar): boolean => {
|
||||
const patterns = grammar.repository?.[TEMPLATE_CALL_KEY]?.patterns;
|
||||
return Array.isArray(patterns) && patterns.length > 0;
|
||||
};
|
||||
|
||||
export const sanitizeTemplateCallGrammar = <T extends TemplateCallGrammar>(grammar: T): T => {
|
||||
if (!hasCatastrophicTemplateCall(grammar)) return grammar;
|
||||
|
||||
const repository = { ...grammar.repository };
|
||||
repository[TEMPLATE_CALL_KEY] = { patterns: [] };
|
||||
return { ...grammar, repository };
|
||||
};
|
||||
|
||||
/** Language ids whose bundled grammars ship the catastrophic `template-call` rule. */
|
||||
export const TEMPLATE_CALL_LANGUAGE_IDS = ['javascript', 'typescript', 'jsx', 'tsx'] as const;
|
||||
|
||||
export type TemplateCallLanguageId = (typeof TEMPLATE_CALL_LANGUAGE_IDS)[number];
|
||||
|
||||
export const isTemplateCallLanguageId = (lang: string): lang is TemplateCallLanguageId =>
|
||||
(TEMPLATE_CALL_LANGUAGE_IDS as readonly string[]).includes(lang);
|
||||
Reference in New Issue
Block a user