diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index c5294f10..2e0482e7 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -1117,18 +1117,14 @@ const SimpleMarkdownRendererImpl: React.FC<{ const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls); - // Content-addressed render cache (see markdownCore) ignores this string for - // lookup; keep a stable per-content key so effect deps stay aligned with text. - const cacheKey = React.useMemo( - () => `simple:${variant}:${renderedContent.length}:${renderedContent.slice(0, 64)}`, - [variant, renderedContent], - ); useMorphdomMarkdown({ containerRef, text: renderedContent, streaming: false, - cacheKey, + // Identity is unused for cache lookup (content-addressed in markdownCore); + // keep a stable per-variant key for effect deps alongside `text`. + cacheKey: `simple:${variant}`, syntaxVars, ctx, }); diff --git a/packages/ui/src/components/chat/markdown/highlightResultCache.ts b/packages/ui/src/components/chat/markdown/highlightResultCache.ts index 1f5fa2cb..1f1b8440 100644 --- a/packages/ui/src/components/chat/markdown/highlightResultCache.ts +++ b/packages/ui/src/components/chat/markdown/highlightResultCache.ts @@ -1,38 +1,61 @@ // Bounded LRU for Shiki highlight results. // -// Used on both the main-thread worker client and inside the markdown-shiki -// worker so unchanged code is never re-tokenized. Keys are exact -// (lang + source [+ theme]); eviction is by entry count and approximate byte -// size so long sessions cannot grow unbounded. +// Used on the main-thread worker client and inside the markdown-shiki worker so +// unchanged code is never re-tokenized. Keys are short content fingerprints +// (not the full source) so cache maps do not duplicate large strings. Entry +// byte sizes are recorded once at insert time — get/evict never re-walk the +// payload. export type HighlightResultCacheOptions = { maxEntries: number; maxBytes: number; }; -const utf8Bytes = (value: string): number => { - // Prefer TextEncoder when available (browser / modern Bun); fall back to - // UTF-16 length × 2 as a conservative upper bound for older runtimes. - if (typeof TextEncoder !== 'undefined') { - return new TextEncoder().encode(value).length; +type CacheEntry = { + value: T; + bytes: number; +}; + +/** UTF-16 storage estimate for a JS string (chars × 2). Avoids TextEncoder allocs. */ +export const utf16Bytes = (value: string): number => value.length * 2; + +/** + * Short stable fingerprint for cache keys: length + FNV-1a 32-bit. + * Collision of same length + same hash is vanishingly rare at session scale; + * highlight results are pure functions of (kind, lang, theme, source). + */ +export const contentFingerprint = (value: string): string => { + let h = 0x811c9dc5; + for (let i = 0; i < value.length; i += 1) { + h ^= value.charCodeAt(i); + h = Math.imul(h, 0x01000193); } - return value.length * 2; + return `${value.length.toString(36)}_${(h >>> 0).toString(36)}`; +}; + +/** Approximate byte cost of token-run lines without JSON.stringify. */ +export const estimateTokenRunsBytes = ( + lines: ReadonlyArray>, +): number => { + let total = 0; + for (const line of lines) { + total += 4; + for (const run of line) { + total += 8 + utf16Bytes(run[1]); + } + } + return total; }; export class HighlightResultCache { private readonly maxEntries: number; private readonly maxBytes: number; - private readonly sizeOf: (key: string, value: T) => number; - private readonly map = new Map(); + private readonly map = new Map>(); private totalBytes = 0; - constructor( - options: HighlightResultCacheOptions, - sizeOf: (key: string, value: T) => number, - ) { + constructor(options: HighlightResultCacheOptions) { this.maxEntries = Math.max(1, options.maxEntries); this.maxBytes = Math.max(1, options.maxBytes); - this.sizeOf = sizeOf; } get size(): number { @@ -44,39 +67,37 @@ export class HighlightResultCache { } get(key: string): T | undefined { - const value = this.map.get(key); - if (value === undefined) return undefined; - // Refresh LRU order. + const entry = this.map.get(key); + if (entry === undefined) return undefined; + // Refresh LRU order without recomputing size. this.map.delete(key); - this.map.set(key, value); - return value; + this.map.set(key, entry); + return entry.value; } - set(key: string, value: T): void { + set(key: string, value: T, bytes: number): void { const existing = this.map.get(key); if (existing !== undefined) { - this.totalBytes -= this.sizeOf(key, existing); + this.totalBytes -= existing.bytes; this.map.delete(key); } - const entrySize = this.sizeOf(key, value); + const entryBytes = Math.max(0, bytes); while ( this.map.size > 0 - && (this.map.size >= this.maxEntries || this.totalBytes + entrySize > this.maxBytes) + && (this.map.size >= this.maxEntries || this.totalBytes + entryBytes > this.maxBytes) ) { const oldest = this.map.keys().next().value; if (oldest === undefined) break; - const oldestValue = this.map.get(oldest); - if (oldestValue !== undefined) { - this.totalBytes -= this.sizeOf(oldest, oldestValue); - } + const oldestEntry = this.map.get(oldest); + if (oldestEntry !== undefined) this.totalBytes -= oldestEntry.bytes; this.map.delete(oldest); // Always allow a single oversized entry so huge files still cache once. if (this.map.size === 0) break; } - this.map.set(key, value); - this.totalBytes += entrySize; + this.map.set(key, { value, bytes: entryBytes }); + this.totalBytes += entryBytes; } clear(): void { @@ -84,14 +105,3 @@ export class HighlightResultCache { this.totalBytes = 0; } } - -/** Byte size for string→string highlight HTML caches (key + value). */ -export const stringPairSize = (key: string, value: string): number => - utf8Bytes(key) + utf8Bytes(value); - -/** Byte size for string→string[] line caches. */ -export const stringLinesSize = (key: string, value: string[]): number => { - let total = utf8Bytes(key); - for (const line of value) total += utf8Bytes(line); - return total; -}; diff --git a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts index 7e19b73c..3877d26a 100644 --- a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts @@ -2,9 +2,10 @@ import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki'; import { + contentFingerprint, + estimateTokenRunsBytes, HighlightResultCache, - stringLinesSize, - stringPairSize, + utf16Bytes, } from './highlightResultCache'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; @@ -37,24 +38,24 @@ let highlighter: ReturnType | undefined; // Serialize work so language loading / tokenization never overlaps. let queue = Promise.resolve(); -// Memoize tokenization by exact source. Without this, every remount / cache -// miss on the main thread re-runs codeToHtml for unchanged content and pins a -// core (openchamber/openchamber#2769). +// Memoize tokenization by content fingerprint. Defense in depth for the +// main-thread client cache (openchamber/openchamber#2769): keys stay short so +// large sources are not duplicated in the Map. const WORKER_CACHE_MAX_ENTRIES = 1500; const WORKER_CACHE_MAX_BYTES = 16 * 1024 * 1024; -const htmlCache = new HighlightResultCache( - { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, - stringPairSize, -); -const linesCache = new HighlightResultCache( - { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, - stringLinesSize, -); -const tokensCache = new HighlightResultCache( - { maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES }, - (key, value) => stringPairSize(key, JSON.stringify(value)), -); +const htmlCache = new HighlightResultCache({ + maxEntries: WORKER_CACHE_MAX_ENTRIES, + maxBytes: WORKER_CACHE_MAX_BYTES, +}); +const linesCache = new HighlightResultCache({ + maxEntries: WORKER_CACHE_MAX_ENTRIES, + maxBytes: WORKER_CACHE_MAX_BYTES, +}); +const tokensCache = new HighlightResultCache({ + maxEntries: WORKER_CACHE_MAX_ENTRIES, + maxBytes: WORKER_CACHE_MAX_BYTES, +}); const ensureHighlighter = (): ReturnType => { highlighter ??= createHighlighter({ @@ -96,9 +97,14 @@ const resolveLanguage = async (instance: Instance, requested: string): Promise `${lang}:${contentFingerprint(code)}`; +const linesKey = (lang: string, code: string): string => `${lang}:${contentFingerprint(code)}`; +const tokensKey = (themeName: string, lang: string, code: string): string => + `${themeName}:${lang}:${contentFingerprint(code)}`; + async function highlight(request: Extract): Promise { try { - const cacheKey = `${request.lang}\0${request.code}`; + const cacheKey = htmlKey(request.lang, request.code); const cached = htmlCache.get(cacheKey); if (cached !== undefined) { post({ type: 'highlight', id: request.id, html: cached }); @@ -111,7 +117,7 @@ async function highlight(request: Extract): Promise { try { - const cacheKey = `${request.themeName}\0${request.lang}\0${request.code}`; + const cacheKey = tokensKey(request.themeName, request.lang, request.code); const cached = tokensCache.get(cacheKey); if (cached !== undefined) { post({ type: 'highlightTokens', id: request.id, lines: cached }); @@ -139,7 +145,7 @@ async function highlightTokens(request: Extract line.map((token) => [token.content.length, token.color ?? '', token.fontStyle ?? 0] as [number, string, number]), ); - tokensCache.set(cacheKey, lines); + tokensCache.set(cacheKey, lines, utf16Bytes(cacheKey) + estimateTokenRunsBytes(lines)); post({ type: 'highlightTokens', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); @@ -148,7 +154,7 @@ async function highlightTokens(request: Extract): Promise { try { - const cacheKey = `${request.lang}\0${request.code}`; + const cacheKey = linesKey(request.lang, request.code); const cached = linesCache.get(cacheKey); if (cached !== undefined) { post({ type: 'highlightLines', id: request.id, lines: cached }); @@ -161,7 +167,9 @@ async function highlightLines(request: Extract line.map(tokenSpan).join('')); - linesCache.set(cacheKey, lines); + let bytes = utf16Bytes(cacheKey); + for (const line of lines) bytes += utf16Bytes(line); + linesCache.set(cacheKey, lines, bytes); post({ type: 'highlightLines', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index 913e6511..a2234c61 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -1,8 +1,9 @@ import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; import { + contentFingerprint, + estimateTokenRunsBytes, HighlightResultCache, - stringLinesSize, - stringPairSize, + utf16Bytes, } from './highlightResultCache'; import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; @@ -12,10 +13,11 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } // tokenization error) the promise resolves to `null` and the caller keeps the // escaped plain-text code — highlighting never falls back onto the main thread. // -// Results are memoized by exact source (+ lang / theme). Unchanged content must -// not re-enter the worker — that was the sustained ~40 msg/s re-highlight load -// in openchamber/openchamber#2769. In-flight requests with the same key coalesce -// so remount storms share one round-trip. +// Results are memoized by content fingerprint (+ lang / theme). Unchanged +// content must not re-enter the worker — that was the sustained ~40 msg/s +// re-highlight load in openchamber/openchamber#2769. In-flight requests with +// the same key coalesce so remount storms share one round-trip. Cache keys are +// fingerprints (not full source) so large files are not duplicated in the Map. type PendingResolver = (response: MarkdownWorkerResponse | null) => void; @@ -27,17 +29,10 @@ type CachedHighlight = const CLIENT_CACHE_MAX_ENTRIES = 2000; const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024; -const cachedHighlightSize = (key: string, value: CachedHighlight): number => { - if (value.type === 'highlight') return stringPairSize(key, value.html); - if (value.type === 'highlightLines') return stringLinesSize(key, value.lines); - // Token runs: approximate as JSON length of the lines payload. - return stringPairSize(key, JSON.stringify(value.lines)); -}; - -const resultCache = new HighlightResultCache( - { maxEntries: CLIENT_CACHE_MAX_ENTRIES, maxBytes: CLIENT_CACHE_MAX_BYTES }, - cachedHighlightSize, -); +const resultCache = new HighlightResultCache({ + maxEntries: CLIENT_CACHE_MAX_ENTRIES, + maxBytes: CLIENT_CACHE_MAX_BYTES, +}); const inflight = new Map>(); @@ -48,6 +43,17 @@ const pending = new Map(); // repeat tokenization sends only the name (not the whole theme object) again. const sentThemes = new Set(); +const entryBytes = (key: string, value: CachedHighlight): number => { + const keyBytes = utf16Bytes(key); + if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html); + if (value.type === 'highlightLines') { + let total = keyBytes; + for (const line of value.lines) total += utf16Bytes(line); + return total; + } + return keyBytes + estimateTokenRunsBytes(value.lines); +}; + const failAll = (): void => { pending.forEach((resolve) => resolve(null)); pending.clear(); @@ -102,6 +108,11 @@ const coalesce = ( return pendingRequest; }; +const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => { + const fp = contentFingerprint(code); + return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`; +}; + /** Test-only: clear client-side highlight memoization. */ export const resetMarkdownWorkerClientCacheForTests = (): void => { resultCache.clear(); @@ -113,7 +124,7 @@ export const resetMarkdownWorkerClientCacheForTests = (): void => { * or `null` if highlighting is unavailable or failed (caller keeps plain code). */ export const highlightCodeInWorker = async (code: string, lang: string): Promise => { - const key = `highlight:${lang}:${code}`; + const key = cacheKeyFor('highlight', lang, code); const cached = resultCache.get(key); if (cached?.type === 'highlight') return cached.html; @@ -121,7 +132,7 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise const response = await request((id) => ({ type: 'highlight', id, code, lang })); if (response?.type !== 'highlight') return null; const entry: CachedHighlight = { type: 'highlight', html: response.html }; - resultCache.set(key, entry); + resultCache.set(key, entry, entryBytes(key, entry)); return entry; }); return result?.type === 'highlight' ? result.html : null; @@ -133,7 +144,7 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise * round-trip instead of one per line. Resolves to `null` on failure. */ export const highlightLinesInWorker = async (code: string, lang: string): Promise => { - const key = `highlightLines:${lang}:${code}`; + const key = cacheKeyFor('highlightLines', lang, code); const cached = resultCache.get(key); if (cached?.type === 'highlightLines') return cached.lines; @@ -141,7 +152,7 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis const response = await request((id) => ({ type: 'highlightLines', id, code, lang })); if (response?.type !== 'highlightLines') return null; const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines }; - resultCache.set(key, entry); + resultCache.set(key, entry, entryBytes(key, entry)); return entry; }); return result?.type === 'highlightLines' ? result.lines : null; @@ -159,7 +170,7 @@ export const highlightTokensInWorker = async ( themeName: string, theme: unknown, ): Promise => { - const key = `highlightTokens:${themeName}:${lang}:${code}`; + const key = cacheKeyFor('highlightTokens', lang, code, themeName); const cached = resultCache.get(key); if (cached?.type === 'highlightTokens') return cached.lines; @@ -176,7 +187,7 @@ export const highlightTokensInWorker = async ( if (response?.type !== 'highlightTokens') return null; sentThemes.add(themeName); const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines }; - resultCache.set(key, entry); + resultCache.set(key, entry, entryBytes(key, entry)); return entry; }); return result?.type === 'highlightTokens' ? result.lines : null; diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index ef998e57..96bc0cc0 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -4,7 +4,7 @@ import katex from 'katex'; import DOMPurify from 'dompurify'; import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks'; import { isVSCodeRuntime } from '@/lib/desktop'; -import { HighlightResultCache, stringPairSize } from './highlightResultCache'; +import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache'; import { highlightCodeInWorker } from './markdown-worker'; import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity'; @@ -254,32 +254,37 @@ const highlightCodeBlocks = async (html: string): Promise => { const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT; - let result = html; - for (const match of matches) { - const [full, rawLang, escapedCode] = match; - const requested = (rawLang || 'text').toLowerCase(); - // Leave mermaid fences untouched so the decorate pass can render them as - // diagrams (highlighting would strip the `language-mermaid` class). - if (requested === 'mermaid') continue; + // Highlight all eligible fences concurrently — sequential await was O(n) + // worker round-trips for messages with multiple code blocks. + const replacements = await Promise.all( + matches.map(async (match) => { + const [full, rawLang, escapedCode] = match; + const requested = (rawLang || 'text').toLowerCase(); + // Leave mermaid fences untouched so the decorate pass can render them as + // diagrams (highlighting would strip the `language-mermaid` class). + if (requested === 'mermaid') return null; - const code = unescapeHtml(escapedCode ?? ''); + const code = unescapeHtml(escapedCode ?? ''); - // Oversized block: skip highlight, keep plain code but stamp the language. - if (exceedsLineLimit(code, lineLimit)) { - result = result.replace(full, () => full.replace(' (no main-thread highlight). - const highlighted = await highlightCodeInWorker(code, requested); - if (highlighted) { + // Tokenize off the main thread. On failure the worker resolves to null and + // we keep the original escaped
 (no main-thread highlight).
+      const highlighted = await highlightCodeInWorker(code, requested);
+      if (!highlighted) return null;
       // Stamp the language so the decorate pass can show a header label.
-      const stamped = highlighted.replace(/^
 stamped);
-    }
-  }
+      return { full, next: highlighted.replace(/^
 replacement.next);
+  }
   return result;
 };
 
@@ -331,20 +336,10 @@ const sanitize = (html: string): string => {
 const HTML_CACHE_MAX_ENTRIES = 2000;
 const HTML_CACHE_MAX_BYTES = 24 * 1024 * 1024;
 
-const htmlCache = new HighlightResultCache(
-  { maxEntries: HTML_CACHE_MAX_ENTRIES, maxBytes: HTML_CACHE_MAX_BYTES },
-  stringPairSize,
-);
-
-// FNV-1a 32-bit hash of the block content.
-const hash = (value: string): string => {
-  let h = 0x811c9dc5;
-  for (let i = 0; i < value.length; i += 1) {
-    h ^= value.charCodeAt(i);
-    h = Math.imul(h, 0x01000193);
-  }
-  return (h >>> 0).toString(36);
-};
+const htmlCache = new HighlightResultCache({
+  maxEntries: HTML_CACHE_MAX_ENTRIES,
+  maxBytes: HTML_CACHE_MAX_BYTES,
+});
 
 /** Content-addressed cache key for a markdown block (exported for tests). */
 export const markdownBlockCacheKey = (
@@ -411,14 +406,14 @@ export const renderMarkdownBlocks = async (
   const blocks = streamBlocks(text, streaming);
   return Promise.all(
     blocks.map(async (block) => {
-      const contentHash = hash(block.raw);
+      const contentHash = contentFingerprint(block.raw);
       const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight);
       const cached = htmlCache.get(id);
       if (cached !== undefined) {
         return { id, html: cached };
       }
       const html = await parseBlock(block);
-      htmlCache.set(id, html);
+      htmlCache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
       return { id, html };
     }),
   );
diff --git a/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
index 82cbb614..e35d5ac9 100644
--- a/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
+++ b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
@@ -8,16 +8,27 @@
  *  3. Worker/client had no result memoization.
  *
  * These tests assert the fixed contracts: content-addressed caching, room for
- * long sessions, and bounded LRU behavior.
+ * long sessions, bounded LRU behavior, and fingerprint-key helpers.
  */
 import { beforeEach, describe, expect, mock, test } from 'bun:test';
 
-import { HighlightResultCache, stringPairSize } from './highlightResultCache';
+import {
+  contentFingerprint,
+  estimateTokenRunsBytes,
+  HighlightResultCache,
+  utf16Bytes,
+} from './highlightResultCache';
 
 let highlightCalls = 0;
+let highlightInflight = 0;
+let highlightMaxInflight = 0;
 
 const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
   highlightCalls += 1;
+  highlightInflight += 1;
+  highlightMaxInflight = Math.max(highlightMaxInflight, highlightInflight);
+  await Promise.resolve();
+  highlightInflight -= 1;
   return `
${code}
`; }); @@ -40,35 +51,45 @@ beforeEach(() => { resetMarkdownHtmlCacheForTests(); resetMarkdownWorkerClientCacheForTests(); highlightCalls = 0; + highlightInflight = 0; + highlightMaxInflight = 0; }); describe('HighlightResultCache', () => { test('returns cached values for identical keys and refreshes LRU order', () => { - const cache = new HighlightResultCache( - { maxEntries: 2, maxBytes: 10_000 }, - stringPairSize, - ); - cache.set('a', 'one'); - cache.set('b', 'two'); + const cache = new HighlightResultCache({ maxEntries: 2, maxBytes: 10_000 }); + cache.set('a', 'one', utf16Bytes('a') + utf16Bytes('one')); + cache.set('b', 'two', utf16Bytes('b') + utf16Bytes('two')); expect(cache.get('a')).toBe('one'); // Touch `a` so `b` is oldest; inserting `c` should evict `b`. - cache.set('c', 'three'); + cache.set('c', 'three', utf16Bytes('c') + utf16Bytes('three')); expect(cache.get('b')).toEqual(undefined); expect(cache.get('a')).toBe('one'); expect(cache.get('c')).toBe('three'); }); test('evicts by byte budget while still caching a single oversized entry', () => { - const cache = new HighlightResultCache( - { maxEntries: 10, maxBytes: 64 }, - stringPairSize, - ); - cache.set('small', 'x'); - cache.set('huge', 'y'.repeat(200)); + const cache = new HighlightResultCache({ maxEntries: 10, maxBytes: 64 }); + cache.set('small', 'x', utf16Bytes('small') + utf16Bytes('x')); + cache.set('huge', 'y'.repeat(200), utf16Bytes('huge') + utf16Bytes('y'.repeat(200))); expect(cache.get('huge')).toBe('y'.repeat(200)); // Oversized insert cleared prior entries to make room. expect(cache.size).toBe(1); }); + + test('contentFingerprint is stable and length-qualified', () => { + expect(contentFingerprint('const x = 1')).toBe(contentFingerprint('const x = 1')); + expect(contentFingerprint('const x = 1')).not.toBe(contentFingerprint('const x = 2')); + expect(contentFingerprint('ab')).not.toBe(contentFingerprint('abc')); + }); + + test('estimateTokenRunsBytes avoids JSON and stays positive', () => { + const lines: Array> = [ + [[3, '#fff', 0], [1, '', 1]], + [[8, 'var(--md-syntax-keyword)', 0]], + ]; + expect(estimateTokenRunsBytes(lines)).toBeGreaterThan(0); + }); }); describe('markdownCore content-addressed htmlCache (#2769)', () => { @@ -135,4 +156,19 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => { expect(markdownBlockCacheKey('abc', 'live', false)).toBe('abc:live:0'); expect(markdownBlockCacheKey('abc', 'full', true)).not.toBe(markdownBlockCacheKey('abc', 'full', false)); }); + + test('multiple code fences in one document highlight concurrently', async () => { + const multi = [ + '```ts\nconst a = 1;\n```', + '', + '```ts\nconst b = 2;\n```', + '', + '```ts\nconst c = 3;\n```', + ].join('\n'); + + await renderMarkdownBlocks(multi, false, 'multi'); + expect(highlightCalls).toBe(3); + // Sequential awaits would keep max inflight at 1. + expect(highlightMaxInflight).toBeGreaterThan(1); + }); });