Merge pull request #2800 from openchamber/feat/shiki-re-highlighting-performance-dd3a
fix(ui): stop sustained Shiki re-highlight of unchanged markdown (#2769)
This commit is contained in:
@@ -835,7 +835,6 @@ const useMorphdomMarkdown = ({
|
||||
containerRef,
|
||||
text,
|
||||
streaming,
|
||||
cacheKey,
|
||||
imageMode = 'inline',
|
||||
syntaxVars,
|
||||
ctx,
|
||||
@@ -843,7 +842,6 @@ const useMorphdomMarkdown = ({
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
cacheKey: string;
|
||||
imageMode?: MarkdownImageMode;
|
||||
syntaxVars: Record<string, string>;
|
||||
ctx: DecorateContext;
|
||||
@@ -908,7 +906,7 @@ const useMorphdomMarkdown = ({
|
||||
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
let active = true;
|
||||
|
||||
void renderMarkdownBlocks(text, streaming, cacheKey, imageMode).then((blocks) => {
|
||||
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
||||
if (!active) return;
|
||||
const existing = Array.from(target.children) as HTMLElement[];
|
||||
|
||||
@@ -959,7 +957,7 @@ const useMorphdomMarkdown = ({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [containerRef, text, streaming, cacheKey, imageMode, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1040,13 +1038,13 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
// Identity for the fade-in wrapper: a new part/message restarts the animation.
|
||||
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
useMorphdomMarkdown({
|
||||
containerRef,
|
||||
text: content,
|
||||
streaming: live,
|
||||
cacheKey,
|
||||
imageMode: variant === 'assistant' ? 'label' : 'inline',
|
||||
syntaxVars,
|
||||
ctx,
|
||||
@@ -1060,7 +1058,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
|
||||
if (isAnimated) {
|
||||
return (
|
||||
<FadeInOnReveal key={cacheKey} skipAnimation={skipFadeIn}>
|
||||
<FadeInOnReveal key={fadeKey} skipAnimation={skipFadeIn}>
|
||||
{markdownContent}
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
@@ -1137,7 +1135,6 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
containerRef,
|
||||
text: renderedContent,
|
||||
streaming: false,
|
||||
cacheKey: `simple:${variant}`,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Bounded LRU for rendered markdown / Shiki highlight results.
|
||||
//
|
||||
// Used by `markdownCore` (per-block HTML) and by the main-thread markdown
|
||||
// worker client (highlight results) so unchanged content is never re-rendered
|
||||
// or 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;
|
||||
};
|
||||
|
||||
type CacheEntry<T> = {
|
||||
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;
|
||||
|
||||
/** Final avalanche so near-identical sources do not land in adjacent buckets. */
|
||||
const mix32 = (hash: number): number => {
|
||||
let h = hash;
|
||||
h ^= h >>> 16;
|
||||
h = Math.imul(h, 0x85ebca6b);
|
||||
h ^= h >>> 13;
|
||||
return h >>> 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Short stable fingerprint for cache keys: length + two independent 32-bit
|
||||
* multiplicative hashes (~64 bits of key space).
|
||||
*
|
||||
* These caches are content-addressed and global, so a collision does not merely
|
||||
* mis-color a block — the cache returns a *different* block's rendered HTML and
|
||||
* the user is shown source they never wrote. One 32-bit hash is not enough for
|
||||
* that failure mode: a few thousand same-length entries reach a birthday
|
||||
* collision probability worth caring about, and the result would be
|
||||
* undiagnosable in the field. Two multiplies per character are free next to
|
||||
* Shiki tokenization.
|
||||
*/
|
||||
export const contentFingerprint = (value: string): string => {
|
||||
let h1 = 0x811c9dc5;
|
||||
let h2 = 0xc2b2ae35;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const code = value.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ code, 0x01000193);
|
||||
h2 = Math.imul(h2 ^ code, 0x27220a95);
|
||||
}
|
||||
return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`;
|
||||
};
|
||||
|
||||
/** Approximate byte cost of token-run lines without JSON.stringify. */
|
||||
export const estimateTokenRunsBytes = (
|
||||
lines: ReadonlyArray<ReadonlyArray<readonly [number, string, number]>>,
|
||||
): 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<T> {
|
||||
private readonly maxEntries: number;
|
||||
private readonly maxBytes: number;
|
||||
private readonly map = new Map<string, CacheEntry<T>>();
|
||||
private totalBytes = 0;
|
||||
|
||||
constructor(options: HighlightResultCacheOptions) {
|
||||
this.maxEntries = Math.max(1, options.maxEntries);
|
||||
this.maxBytes = Math.max(1, options.maxBytes);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
get bytes(): number {
|
||||
return this.totalBytes;
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
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, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: T, bytes: number): void {
|
||||
const existing = this.map.get(key);
|
||||
if (existing !== undefined) {
|
||||
this.totalBytes -= existing.bytes;
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
const entryBytes = Math.max(0, bytes);
|
||||
while (
|
||||
this.map.size > 0
|
||||
&& (this.map.size >= this.maxEntries || this.totalBytes + entryBytes > this.maxBytes)
|
||||
) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
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, bytes: entryBytes });
|
||||
this.totalBytes += entryBytes;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
this.totalBytes = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
|
||||
import {
|
||||
contentFingerprint,
|
||||
estimateTokenRunsBytes,
|
||||
HighlightResultCache,
|
||||
utf16Bytes,
|
||||
} from './highlightResultCache';
|
||||
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
||||
|
||||
// Main-thread client for the markdown Shiki worker. Moves syntax tokenization
|
||||
@@ -6,9 +12,39 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse }
|
||||
// ready-to-splice Shiki HTML. On any failure (no worker support, worker crash,
|
||||
// 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 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.
|
||||
//
|
||||
// This module is the only sender to the worker, so memoizing here is sufficient
|
||||
// and the worker itself stays stateless apart from the Shiki instance. A second
|
||||
// cache inside the worker would only duplicate these payloads in another heap.
|
||||
//
|
||||
// `highlight` / `highlightLines` results are theme-independent: the worker
|
||||
// tokenizes with the CSS-variable `MARKDOWN_SHIKI_THEME`, so a theme switch
|
||||
// repaints via CSS and must not invalidate these entries. Only
|
||||
// `highlightTokens` resolves concrete colors, so only its key carries a theme.
|
||||
|
||||
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
|
||||
|
||||
type CachedHighlight =
|
||||
| { type: 'highlight'; html: string }
|
||||
| { type: 'highlightLines'; lines: string[] }
|
||||
| { type: 'highlightTokens'; lines: MarkdownTokenRun[][] };
|
||||
|
||||
const CLIENT_CACHE_MAX_ENTRIES = 2000;
|
||||
const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
||||
|
||||
const resultCache = new HighlightResultCache<CachedHighlight>({
|
||||
maxEntries: CLIENT_CACHE_MAX_ENTRIES,
|
||||
maxBytes: CLIENT_CACHE_MAX_BYTES,
|
||||
});
|
||||
|
||||
const inflight = new Map<string, Promise<CachedHighlight | null>>();
|
||||
|
||||
let worker: Worker | undefined;
|
||||
let nextId = 0;
|
||||
const pending = new Map<number, PendingResolver>();
|
||||
@@ -16,10 +52,23 @@ const pending = new Map<number, PendingResolver>();
|
||||
// repeat tokenization sends only the name (not the whole theme object) again.
|
||||
const sentThemes = new Set<string>();
|
||||
|
||||
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();
|
||||
sentThemes.clear();
|
||||
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
|
||||
inflight.clear();
|
||||
worker?.terminate();
|
||||
worker = undefined;
|
||||
};
|
||||
@@ -55,13 +104,47 @@ const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<Markdo
|
||||
});
|
||||
};
|
||||
|
||||
const coalesce = (
|
||||
key: string,
|
||||
run: () => Promise<CachedHighlight | null>,
|
||||
): Promise<CachedHighlight | null> => {
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
const pendingRequest = run().finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, pendingRequest);
|
||||
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();
|
||||
inflight.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight a complete code block in the worker. Resolves to Shiki `<pre>` HTML,
|
||||
* or `null` if highlighting is unavailable or failed (caller keeps plain code).
|
||||
*/
|
||||
export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
|
||||
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
|
||||
return response?.type === 'highlight' ? response.html : null;
|
||||
const key = cacheKeyFor('highlight', lang, code);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlight') return cached.html;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
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, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
return result?.type === 'highlight' ? result.html : null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,8 +153,18 @@ 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<string[] | null> => {
|
||||
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
||||
return response?.type === 'highlightLines' ? response.lines : null;
|
||||
const key = cacheKeyFor('highlightLines', lang, code);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlightLines') return cached.lines;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
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, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
return result?.type === 'highlightLines' ? result.lines : null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -86,18 +179,25 @@ export const highlightTokensInWorker = async (
|
||||
themeName: string,
|
||||
theme: unknown,
|
||||
): Promise<MarkdownTokenRun[][] | null> => {
|
||||
const needsTheme = !sentThemes.has(themeName);
|
||||
const response = await request((id) => ({
|
||||
type: 'highlightTokens',
|
||||
id,
|
||||
code,
|
||||
lang,
|
||||
themeName,
|
||||
...(needsTheme ? { theme } : {}),
|
||||
}));
|
||||
if (response?.type === 'highlightTokens') {
|
||||
const key = cacheKeyFor('highlightTokens', lang, code, themeName);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlightTokens') return cached.lines;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
const needsTheme = !sentThemes.has(themeName);
|
||||
const response = await request((id) => ({
|
||||
type: 'highlightTokens',
|
||||
id,
|
||||
code,
|
||||
lang,
|
||||
themeName,
|
||||
...(needsTheme ? { theme } : {}),
|
||||
}));
|
||||
if (response?.type !== 'highlightTokens') return null;
|
||||
sentThemes.add(themeName);
|
||||
return response.lines;
|
||||
}
|
||||
return null;
|
||||
const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
return result?.type === 'highlightTokens' ? result.lines : null;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
|
||||
import { highlightCodeInWorker } from './markdown-worker';
|
||||
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
|
||||
@@ -415,32 +416,37 @@ const highlightCodeBlocks = async (html: string): Promise<string> => {
|
||||
|
||||
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('<pre', `<pre data-md-lang="${requested}"`));
|
||||
continue;
|
||||
}
|
||||
// Oversized block: skip highlight, keep plain code but stamp the language.
|
||||
if (exceedsLineLimit(code, lineLimit)) {
|
||||
return { full, next: full.replace('<pre', `<pre data-md-lang="${requested}"`) };
|
||||
}
|
||||
|
||||
// Tokenize off the main thread. On failure the worker resolves to null and
|
||||
// we keep the original escaped <pre><code> (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 <pre><code> (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(/^<pre/, `<pre data-md-lang="${requested}"`);
|
||||
result = result.replace(full, () => stamped);
|
||||
}
|
||||
}
|
||||
return { full, next: highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`) };
|
||||
}),
|
||||
);
|
||||
|
||||
let result = html;
|
||||
for (const replacement of replacements) {
|
||||
if (!replacement) continue;
|
||||
result = result.replace(replacement.full, () => replacement.next);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -483,29 +489,60 @@ const sanitize = (html: string): string => {
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
|
||||
// Per-block HTML cache (content-addressed LRU)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Keyed by content hash + mode + highlight flag + image mode — NOT by renderer
|
||||
// instance id. `SimpleMarkdownRenderer` historically used a shared
|
||||
// `simple:${variant}` key, so every same-variant instance fought over one cache
|
||||
// slot and re-highlighted unchanged content on every pass
|
||||
// (openchamber/openchamber#2769). Content addressing makes identical blocks
|
||||
// share one entry and stops that thrash. Bounds are high enough for long
|
||||
// sessions; byte cap keeps memory bounded.
|
||||
//
|
||||
// `full` (settled) and `live` (trailing, still streaming) blocks get separate
|
||||
// caches. A live block's content changes on every stream step, so under one
|
||||
// shared content-addressed cache each step would insert a new entry and a long
|
||||
// streaming message would evict the settled blocks this fix exists to keep
|
||||
// warm. The live cache is small on purpose: it only has to absorb repeat
|
||||
// renders of the *same* step.
|
||||
|
||||
const CACHE_MAX = 240;
|
||||
const htmlCache = new Map<string, { hash: string; html: string }>();
|
||||
const FULL_CACHE_MAX_ENTRIES = 2000;
|
||||
const FULL_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
||||
const LIVE_CACHE_MAX_ENTRIES = 32;
|
||||
const LIVE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// 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 fullBlockCache = new HighlightResultCache<string>({
|
||||
maxEntries: FULL_CACHE_MAX_ENTRIES,
|
||||
maxBytes: FULL_CACHE_MAX_BYTES,
|
||||
});
|
||||
const liveBlockCache = new HighlightResultCache<string>({
|
||||
maxEntries: LIVE_CACHE_MAX_ENTRIES,
|
||||
maxBytes: LIVE_CACHE_MAX_BYTES,
|
||||
});
|
||||
|
||||
const cacheForMode = (mode: MarkdownBlock['mode']): HighlightResultCache<string> =>
|
||||
(mode === 'live' ? liveBlockCache : fullBlockCache);
|
||||
|
||||
/** Content-addressed cache key for a markdown block. */
|
||||
const markdownBlockCacheKey = (
|
||||
contentHash: string,
|
||||
mode: MarkdownBlock['mode'],
|
||||
highlight: boolean,
|
||||
imageMode: MarkdownImageMode,
|
||||
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
|
||||
|
||||
/** Test-only: clear the render HTML caches between cases. */
|
||||
export const resetMarkdownHtmlCacheForTests = (): void => {
|
||||
fullBlockCache.clear();
|
||||
liveBlockCache.clear();
|
||||
};
|
||||
|
||||
const touch = (key: string, entry: { hash: string; html: string }): void => {
|
||||
htmlCache.delete(key);
|
||||
htmlCache.set(key, entry);
|
||||
if (htmlCache.size <= CACHE_MAX) return;
|
||||
const oldest = htmlCache.keys().next().value;
|
||||
if (oldest) htmlCache.delete(oldest);
|
||||
};
|
||||
/** Test-only: entry counts per block cache, for churn/eviction assertions. */
|
||||
export const __markdownBlockCacheSizesForTests = (): { full: number; live: number } => ({
|
||||
full: fullBlockCache.size,
|
||||
live: liveBlockCache.size,
|
||||
});
|
||||
|
||||
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
@@ -545,28 +582,29 @@ export type RenderedBlock = {
|
||||
* splits into blocks, caches per-block, heals incomplete syntax. Returning
|
||||
* blocks (instead of one joined string) lets the renderer re-morph only the
|
||||
* block that changed, keeping per-step streaming cost ~O(last block).
|
||||
*
|
||||
* Lookup is content-addressed: distinct renderers holding identical blocks
|
||||
* share one entry and cannot evict each other by identity collision.
|
||||
*/
|
||||
export const renderMarkdownBlocks = async (
|
||||
text: string,
|
||||
streaming: boolean,
|
||||
cacheKey: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): Promise<RenderedBlock[]> => {
|
||||
if (!text) return [];
|
||||
|
||||
const blocks = streamBlocks(text, streaming);
|
||||
return Promise.all(
|
||||
blocks.map(async (block, index) => {
|
||||
const contentHash = hash(block.raw);
|
||||
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${imageMode}`;
|
||||
const key = `${cacheKey}:${index}:${block.mode}:${imageMode}`;
|
||||
const cached = htmlCache.get(key);
|
||||
if (cached && cached.hash === contentHash) {
|
||||
touch(key, cached);
|
||||
return { id, html: cached.html };
|
||||
blocks.map(async (block) => {
|
||||
const contentHash = contentFingerprint(block.raw);
|
||||
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
|
||||
const cache = cacheForMode(block.mode);
|
||||
const cached = cache.get(id);
|
||||
if (cached !== undefined) {
|
||||
return { id, html: cached };
|
||||
}
|
||||
const html = await parseBlock(block, imageMode);
|
||||
touch(key, { hash: contentHash, html });
|
||||
cache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
|
||||
return { id, html };
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Regression tests for https://github.com/openchamber/openchamber/issues/2769
|
||||
*
|
||||
* Sustained Shiki worker CPU came from re-tokenizing unchanged content:
|
||||
* 1. `htmlCache` keyed by renderer identity (`simple:${variant}`) so
|
||||
* same-variant instances evicted each other every pass.
|
||||
* 2. LRU capped at 240 entries, so long sessions missed 100% on every pass.
|
||||
* 3. Worker/client had no result memoization.
|
||||
*
|
||||
* These tests assert the fixed contracts: content-addressed caching, room for
|
||||
* long sessions, bounded LRU behavior, and fingerprint-key helpers.
|
||||
*/
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
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 `<pre data-lang="${lang}"><code>${code}</code></pre>`;
|
||||
});
|
||||
|
||||
mock.module('./markdown-worker', () => ({
|
||||
highlightCodeInWorker: highlightCodeInWorkerMock,
|
||||
highlightLinesInWorker: mock(async () => null),
|
||||
highlightTokensInWorker: mock(async () => null),
|
||||
resetMarkdownWorkerClientCacheForTests: mock(() => undefined),
|
||||
}));
|
||||
|
||||
const {
|
||||
renderMarkdownBlocks,
|
||||
resetMarkdownHtmlCacheForTests,
|
||||
__markdownBlockCacheSizesForTests,
|
||||
} = await import('./markdownCore');
|
||||
|
||||
const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
|
||||
|
||||
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<string>({ 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', 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<string>({ 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('contentFingerprint stays collision-free across a realistic session', () => {
|
||||
// A collision here does not mis-color a block — it returns a *different*
|
||||
// block's HTML, showing the user source they never wrote. Keep enough key
|
||||
// space that a session-sized working set never collides.
|
||||
const seen = new Map<string, string>();
|
||||
for (let i = 0; i < 20_000; i += 1) {
|
||||
// Same-length, near-identical sources are the realistic worst case:
|
||||
// repeated tool output differing by a few characters.
|
||||
const source = `const value_${String(i).padStart(6, '0')} = ${String(i).padStart(6, '0')};`;
|
||||
const fingerprint = contentFingerprint(source);
|
||||
expect(seen.get(fingerprint) ?? source).toBe(source);
|
||||
seen.set(fingerprint, source);
|
||||
}
|
||||
expect(seen.size).toBe(20_000);
|
||||
});
|
||||
|
||||
test('estimateTokenRunsBytes avoids JSON and stays positive', () => {
|
||||
const lines: Array<Array<[number, string, number]>> = [
|
||||
[[3, '#fff', 0], [1, '', 1]],
|
||||
[[8, 'var(--md-syntax-keyword)', 0]],
|
||||
];
|
||||
expect(estimateTokenRunsBytes(lines)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdownCore content-addressed htmlCache (#2769)', () => {
|
||||
test('repeat renders of unchanged content never re-enter the worker', async () => {
|
||||
const toolOutputA = '```ts\nconst a = 1;\n```';
|
||||
const toolOutputB = '```ts\nconst b = 2;\n```';
|
||||
|
||||
// First pass: cold miss for each distinct block.
|
||||
await renderMarkdownBlocks(toolOutputA, false);
|
||||
await renderMarkdownBlocks(toolOutputB, false);
|
||||
const coldCalls = highlightCalls;
|
||||
expect(coldCalls).toBeGreaterThan(0);
|
||||
|
||||
// 100 more passes. Renderers used to pass a shared `simple:${variant}`
|
||||
// identity key here and evict each other every pass; lookup is now
|
||||
// content-addressed, so no additional worker calls may happen.
|
||||
for (let pass = 0; pass < 100; pass += 1) {
|
||||
await renderMarkdownBlocks(toolOutputA, false);
|
||||
await renderMarkdownBlocks(toolOutputB, false);
|
||||
}
|
||||
|
||||
expect(highlightCalls).toBe(coldCalls);
|
||||
});
|
||||
|
||||
test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => {
|
||||
const parts = Array.from({ length: 600 }, (_, i) => ({
|
||||
content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``,
|
||||
}));
|
||||
|
||||
for (const part of parts) {
|
||||
await renderMarkdownBlocks(part.content, false);
|
||||
}
|
||||
const afterCold = highlightCalls;
|
||||
expect(afterCold).toBe(parts.length);
|
||||
|
||||
for (let pass = 0; pass < 5; pass += 1) {
|
||||
for (const part of parts) {
|
||||
await renderMarkdownBlocks(part.content, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Unchanged content must not re-enter the worker.
|
||||
expect(highlightCalls).toBe(afterCold);
|
||||
});
|
||||
|
||||
test('content changes invalidate only the changed block', async () => {
|
||||
const stable = '```ts\nconst stable = true;\n```';
|
||||
const changing = '```ts\nconst n = 1;\n```';
|
||||
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
await renderMarkdownBlocks(changing, false);
|
||||
const afterFirst = highlightCalls;
|
||||
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false);
|
||||
expect(highlightCalls).toBe(afterFirst + 1);
|
||||
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
expect(highlightCalls).toBe(afterFirst + 1);
|
||||
});
|
||||
|
||||
test('image mode is part of the cache identity, not shared across modes', async () => {
|
||||
const source = '';
|
||||
|
||||
const [inline] = await renderMarkdownBlocks(source, false, 'inline');
|
||||
expect(__markdownBlockCacheSizesForTests().full).toBe(1);
|
||||
|
||||
// Same source, different rendering: content addressing must not let the
|
||||
// first-rendered mode answer for both.
|
||||
const [label] = await renderMarkdownBlocks(source, false, 'label');
|
||||
expect(inline?.id).not.toBe(label?.id);
|
||||
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
|
||||
|
||||
// Re-rendering a mode already seen stays a cache hit.
|
||||
const [inlineAgain] = await renderMarkdownBlocks(source, false, 'inline');
|
||||
expect(inlineAgain?.id).toBe(inline?.id);
|
||||
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
|
||||
});
|
||||
|
||||
test('streaming a message does not evict settled blocks (live cache is separate)', async () => {
|
||||
const settled = Array.from(
|
||||
{ length: 40 },
|
||||
(_, i) => `\`\`\`ts\nconst settled_${i} = ${i};\n\`\`\``,
|
||||
);
|
||||
for (const block of settled) {
|
||||
await renderMarkdownBlocks(block, false);
|
||||
}
|
||||
const settledEntries = __markdownBlockCacheSizesForTests().full;
|
||||
expect(settledEntries).toBe(settled.length);
|
||||
const afterSettled = highlightCalls;
|
||||
|
||||
// Stream a message: every step is new content for the trailing live block,
|
||||
// so a single shared content-addressed cache would insert one entry per
|
||||
// step and evict the settled working set this fix exists to keep warm.
|
||||
let streamed = '';
|
||||
for (let step = 0; step < 150; step += 1) {
|
||||
streamed += `word_${step} `;
|
||||
await renderMarkdownBlocks(streamed, true);
|
||||
}
|
||||
|
||||
const sizes = __markdownBlockCacheSizesForTests();
|
||||
expect(sizes.live).toBeLessThanOrEqual(32);
|
||||
expect(sizes.full).toBe(settledEntries);
|
||||
|
||||
for (const block of settled) {
|
||||
await renderMarkdownBlocks(block, false);
|
||||
}
|
||||
expect(highlightCalls).toBe(afterSettled);
|
||||
});
|
||||
|
||||
test('a repeated streaming step is served from the live cache', async () => {
|
||||
const step = 'partial answer text';
|
||||
const [first] = await renderMarkdownBlocks(step, true);
|
||||
const [second] = await renderMarkdownBlocks(step, true);
|
||||
|
||||
expect(second?.id).toBe(first?.id);
|
||||
expect(__markdownBlockCacheSizesForTests()).toEqual({ full: 0, live: 1 });
|
||||
});
|
||||
|
||||
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);
|
||||
expect(highlightCalls).toBe(3);
|
||||
// Sequential awaits would keep max inflight at 1.
|
||||
expect(highlightMaxInflight).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
Vendored
+1
@@ -19,6 +19,7 @@ declare module "bun:test" {
|
||||
toBeGreaterThan(expected: number): void;
|
||||
toBeGreaterThanOrEqual(expected: number): void;
|
||||
toBeLessThan(expected: number): void;
|
||||
toBeLessThanOrEqual(expected: number): void;
|
||||
toHaveLength(expected: number): void;
|
||||
toBeInstanceOf(expected: unknown): void;
|
||||
not: {
|
||||
|
||||
Reference in New Issue
Block a user