perf(ui): tighten Shiki highlight caches and parallelize fences
Use content fingerprints instead of full source as cache keys, record entry sizes once (no TextEncoder/JSON.stringify on get/evict), and highlight multiple markdown fences concurrently. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
6ab12fdb61
commit
c3c47e8956
@@ -1117,18 +1117,14 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
|||||||
|
|
||||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||||
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
|
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({
|
useMorphdomMarkdown({
|
||||||
containerRef,
|
containerRef,
|
||||||
text: renderedContent,
|
text: renderedContent,
|
||||||
streaming: false,
|
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,
|
syntaxVars,
|
||||||
ctx,
|
ctx,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +1,61 @@
|
|||||||
// Bounded LRU for Shiki highlight results.
|
// Bounded LRU for Shiki highlight results.
|
||||||
//
|
//
|
||||||
// Used on both the main-thread worker client and inside the markdown-shiki
|
// Used on the main-thread worker client and inside the markdown-shiki worker so
|
||||||
// worker so unchanged code is never re-tokenized. Keys are exact
|
// unchanged code is never re-tokenized. Keys are short content fingerprints
|
||||||
// (lang + source [+ theme]); eviction is by entry count and approximate byte
|
// (not the full source) so cache maps do not duplicate large strings. Entry
|
||||||
// size so long sessions cannot grow unbounded.
|
// byte sizes are recorded once at insert time — get/evict never re-walk the
|
||||||
|
// payload.
|
||||||
|
|
||||||
export type HighlightResultCacheOptions = {
|
export type HighlightResultCacheOptions = {
|
||||||
maxEntries: number;
|
maxEntries: number;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const utf8Bytes = (value: string): number => {
|
type CacheEntry<T> = {
|
||||||
// Prefer TextEncoder when available (browser / modern Bun); fall back to
|
value: T;
|
||||||
// UTF-16 length × 2 as a conservative upper bound for older runtimes.
|
bytes: number;
|
||||||
if (typeof TextEncoder !== 'undefined') {
|
};
|
||||||
return new TextEncoder().encode(value).length;
|
|
||||||
|
/** 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<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> {
|
export class HighlightResultCache<T> {
|
||||||
private readonly maxEntries: number;
|
private readonly maxEntries: number;
|
||||||
private readonly maxBytes: number;
|
private readonly maxBytes: number;
|
||||||
private readonly sizeOf: (key: string, value: T) => number;
|
private readonly map = new Map<string, CacheEntry<T>>();
|
||||||
private readonly map = new Map<string, T>();
|
|
||||||
private totalBytes = 0;
|
private totalBytes = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(options: HighlightResultCacheOptions) {
|
||||||
options: HighlightResultCacheOptions,
|
|
||||||
sizeOf: (key: string, value: T) => number,
|
|
||||||
) {
|
|
||||||
this.maxEntries = Math.max(1, options.maxEntries);
|
this.maxEntries = Math.max(1, options.maxEntries);
|
||||||
this.maxBytes = Math.max(1, options.maxBytes);
|
this.maxBytes = Math.max(1, options.maxBytes);
|
||||||
this.sizeOf = sizeOf;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get size(): number {
|
get size(): number {
|
||||||
@@ -44,39 +67,37 @@ export class HighlightResultCache<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get(key: string): T | undefined {
|
get(key: string): T | undefined {
|
||||||
const value = this.map.get(key);
|
const entry = this.map.get(key);
|
||||||
if (value === undefined) return undefined;
|
if (entry === undefined) return undefined;
|
||||||
// Refresh LRU order.
|
// Refresh LRU order without recomputing size.
|
||||||
this.map.delete(key);
|
this.map.delete(key);
|
||||||
this.map.set(key, value);
|
this.map.set(key, entry);
|
||||||
return value;
|
return entry.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
set(key: string, value: T): void {
|
set(key: string, value: T, bytes: number): void {
|
||||||
const existing = this.map.get(key);
|
const existing = this.map.get(key);
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
this.totalBytes -= this.sizeOf(key, existing);
|
this.totalBytes -= existing.bytes;
|
||||||
this.map.delete(key);
|
this.map.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
const entrySize = this.sizeOf(key, value);
|
const entryBytes = Math.max(0, bytes);
|
||||||
while (
|
while (
|
||||||
this.map.size > 0
|
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;
|
const oldest = this.map.keys().next().value;
|
||||||
if (oldest === undefined) break;
|
if (oldest === undefined) break;
|
||||||
const oldestValue = this.map.get(oldest);
|
const oldestEntry = this.map.get(oldest);
|
||||||
if (oldestValue !== undefined) {
|
if (oldestEntry !== undefined) this.totalBytes -= oldestEntry.bytes;
|
||||||
this.totalBytes -= this.sizeOf(oldest, oldestValue);
|
|
||||||
}
|
|
||||||
this.map.delete(oldest);
|
this.map.delete(oldest);
|
||||||
// Always allow a single oversized entry so huge files still cache once.
|
// Always allow a single oversized entry so huge files still cache once.
|
||||||
if (this.map.size === 0) break;
|
if (this.map.size === 0) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.map.set(key, value);
|
this.map.set(key, { value, bytes: entryBytes });
|
||||||
this.totalBytes += entrySize;
|
this.totalBytes += entryBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
@@ -84,14 +105,3 @@ export class HighlightResultCache<T> {
|
|||||||
this.totalBytes = 0;
|
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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki';
|
import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki';
|
||||||
import {
|
import {
|
||||||
|
contentFingerprint,
|
||||||
|
estimateTokenRunsBytes,
|
||||||
HighlightResultCache,
|
HighlightResultCache,
|
||||||
stringLinesSize,
|
utf16Bytes,
|
||||||
stringPairSize,
|
|
||||||
} from './highlightResultCache';
|
} from './highlightResultCache';
|
||||||
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
|
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
|
||||||
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
||||||
@@ -37,24 +38,24 @@ let highlighter: ReturnType<typeof createHighlighter> | undefined;
|
|||||||
// Serialize work so language loading / tokenization never overlaps.
|
// Serialize work so language loading / tokenization never overlaps.
|
||||||
let queue = Promise.resolve();
|
let queue = Promise.resolve();
|
||||||
|
|
||||||
// Memoize tokenization by exact source. Without this, every remount / cache
|
// Memoize tokenization by content fingerprint. Defense in depth for the
|
||||||
// miss on the main thread re-runs codeToHtml for unchanged content and pins a
|
// main-thread client cache (openchamber/openchamber#2769): keys stay short so
|
||||||
// core (openchamber/openchamber#2769).
|
// large sources are not duplicated in the Map.
|
||||||
const WORKER_CACHE_MAX_ENTRIES = 1500;
|
const WORKER_CACHE_MAX_ENTRIES = 1500;
|
||||||
const WORKER_CACHE_MAX_BYTES = 16 * 1024 * 1024;
|
const WORKER_CACHE_MAX_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
const htmlCache = new HighlightResultCache<string>(
|
const htmlCache = new HighlightResultCache<string>({
|
||||||
{ maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES },
|
maxEntries: WORKER_CACHE_MAX_ENTRIES,
|
||||||
stringPairSize,
|
maxBytes: WORKER_CACHE_MAX_BYTES,
|
||||||
);
|
});
|
||||||
const linesCache = new HighlightResultCache<string[]>(
|
const linesCache = new HighlightResultCache<string[]>({
|
||||||
{ maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES },
|
maxEntries: WORKER_CACHE_MAX_ENTRIES,
|
||||||
stringLinesSize,
|
maxBytes: WORKER_CACHE_MAX_BYTES,
|
||||||
);
|
});
|
||||||
const tokensCache = new HighlightResultCache<MarkdownTokenRun[][]>(
|
const tokensCache = new HighlightResultCache<MarkdownTokenRun[][]>({
|
||||||
{ maxEntries: WORKER_CACHE_MAX_ENTRIES, maxBytes: WORKER_CACHE_MAX_BYTES },
|
maxEntries: WORKER_CACHE_MAX_ENTRIES,
|
||||||
(key, value) => stringPairSize(key, JSON.stringify(value)),
|
maxBytes: WORKER_CACHE_MAX_BYTES,
|
||||||
);
|
});
|
||||||
|
|
||||||
const ensureHighlighter = (): ReturnType<typeof createHighlighter> => {
|
const ensureHighlighter = (): ReturnType<typeof createHighlighter> => {
|
||||||
highlighter ??= createHighlighter({
|
highlighter ??= createHighlighter({
|
||||||
@@ -96,9 +97,14 @@ const resolveLanguage = async (instance: Instance, requested: string): Promise<s
|
|||||||
return lang;
|
return lang;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const htmlKey = (lang: string, code: string): string => `${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<MarkdownWorkerRequest, { type: 'highlight' }>): Promise<void> {
|
async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highlight' }>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cacheKey = `${request.lang}\0${request.code}`;
|
const cacheKey = htmlKey(request.lang, request.code);
|
||||||
const cached = htmlCache.get(cacheKey);
|
const cached = htmlCache.get(cacheKey);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
post({ type: 'highlight', id: request.id, html: cached });
|
post({ type: 'highlight', id: request.id, html: cached });
|
||||||
@@ -111,7 +117,7 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highli
|
|||||||
theme: MARKDOWN_SHIKI_THEME,
|
theme: MARKDOWN_SHIKI_THEME,
|
||||||
tabindex: false,
|
tabindex: false,
|
||||||
});
|
});
|
||||||
htmlCache.set(cacheKey, html);
|
htmlCache.set(cacheKey, html, utf16Bytes(cacheKey) + utf16Bytes(html));
|
||||||
post({ type: 'highlight', id: request.id, html });
|
post({ type: 'highlight', id: request.id, html });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
|
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
|
||||||
@@ -120,7 +126,7 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highli
|
|||||||
|
|
||||||
async function highlightTokens(request: Extract<MarkdownWorkerRequest, { type: 'highlightTokens' }>): Promise<void> {
|
async function highlightTokens(request: Extract<MarkdownWorkerRequest, { type: 'highlightTokens' }>): Promise<void> {
|
||||||
try {
|
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);
|
const cached = tokensCache.get(cacheKey);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
post({ type: 'highlightTokens', id: request.id, lines: cached });
|
post({ type: 'highlightTokens', id: request.id, lines: cached });
|
||||||
@@ -139,7 +145,7 @@ async function highlightTokens(request: Extract<MarkdownWorkerRequest, { type: '
|
|||||||
const lines = tokens.map((line) =>
|
const lines = tokens.map((line) =>
|
||||||
line.map((token) => [token.content.length, token.color ?? '', token.fontStyle ?? 0] as [number, string, number]),
|
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 });
|
post({ type: 'highlightTokens', id: request.id, lines });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
|
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
|
||||||
@@ -148,7 +154,7 @@ async function highlightTokens(request: Extract<MarkdownWorkerRequest, { type: '
|
|||||||
|
|
||||||
async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'highlightLines' }>): Promise<void> {
|
async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'highlightLines' }>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cacheKey = `${request.lang}\0${request.code}`;
|
const cacheKey = linesKey(request.lang, request.code);
|
||||||
const cached = linesCache.get(cacheKey);
|
const cached = linesCache.get(cacheKey);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
post({ type: 'highlightLines', id: request.id, lines: cached });
|
post({ type: 'highlightLines', id: request.id, lines: cached });
|
||||||
@@ -161,7 +167,9 @@ async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'h
|
|||||||
theme: MARKDOWN_SHIKI_THEME,
|
theme: MARKDOWN_SHIKI_THEME,
|
||||||
});
|
});
|
||||||
const lines = tokens.map((line) => line.map(tokenSpan).join(''));
|
const lines = tokens.map((line) => 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 });
|
post({ type: 'highlightLines', id: request.id, lines });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
|
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
|
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
|
||||||
import {
|
import {
|
||||||
|
contentFingerprint,
|
||||||
|
estimateTokenRunsBytes,
|
||||||
HighlightResultCache,
|
HighlightResultCache,
|
||||||
stringLinesSize,
|
utf16Bytes,
|
||||||
stringPairSize,
|
|
||||||
} from './highlightResultCache';
|
} from './highlightResultCache';
|
||||||
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
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
|
// tokenization error) the promise resolves to `null` and the caller keeps the
|
||||||
// escaped plain-text code — highlighting never falls back onto the main thread.
|
// escaped plain-text code — highlighting never falls back onto the main thread.
|
||||||
//
|
//
|
||||||
// Results are memoized by exact source (+ lang / theme). Unchanged content must
|
// Results are memoized by content fingerprint (+ lang / theme). Unchanged
|
||||||
// not re-enter the worker — that was the sustained ~40 msg/s re-highlight load
|
// content must not re-enter the worker — that was the sustained ~40 msg/s
|
||||||
// in openchamber/openchamber#2769. In-flight requests with the same key coalesce
|
// re-highlight load in openchamber/openchamber#2769. In-flight requests with
|
||||||
// so remount storms share one round-trip.
|
// 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;
|
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
|
||||||
|
|
||||||
@@ -27,17 +29,10 @@ type CachedHighlight =
|
|||||||
const CLIENT_CACHE_MAX_ENTRIES = 2000;
|
const CLIENT_CACHE_MAX_ENTRIES = 2000;
|
||||||
const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
||||||
|
|
||||||
const cachedHighlightSize = (key: string, value: CachedHighlight): number => {
|
const resultCache = new HighlightResultCache<CachedHighlight>({
|
||||||
if (value.type === 'highlight') return stringPairSize(key, value.html);
|
maxEntries: CLIENT_CACHE_MAX_ENTRIES,
|
||||||
if (value.type === 'highlightLines') return stringLinesSize(key, value.lines);
|
maxBytes: CLIENT_CACHE_MAX_BYTES,
|
||||||
// Token runs: approximate as JSON length of the lines payload.
|
});
|
||||||
return stringPairSize(key, JSON.stringify(value.lines));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resultCache = new HighlightResultCache<CachedHighlight>(
|
|
||||||
{ maxEntries: CLIENT_CACHE_MAX_ENTRIES, maxBytes: CLIENT_CACHE_MAX_BYTES },
|
|
||||||
cachedHighlightSize,
|
|
||||||
);
|
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<CachedHighlight | null>>();
|
const inflight = new Map<string, Promise<CachedHighlight | null>>();
|
||||||
|
|
||||||
@@ -48,6 +43,17 @@ const pending = new Map<number, PendingResolver>();
|
|||||||
// repeat tokenization sends only the name (not the whole theme object) again.
|
// repeat tokenization sends only the name (not the whole theme object) again.
|
||||||
const sentThemes = new Set<string>();
|
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 => {
|
const failAll = (): void => {
|
||||||
pending.forEach((resolve) => resolve(null));
|
pending.forEach((resolve) => resolve(null));
|
||||||
pending.clear();
|
pending.clear();
|
||||||
@@ -102,6 +108,11 @@ const coalesce = (
|
|||||||
return 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. */
|
/** Test-only: clear client-side highlight memoization. */
|
||||||
export const resetMarkdownWorkerClientCacheForTests = (): void => {
|
export const resetMarkdownWorkerClientCacheForTests = (): void => {
|
||||||
resultCache.clear();
|
resultCache.clear();
|
||||||
@@ -113,7 +124,7 @@ export const resetMarkdownWorkerClientCacheForTests = (): void => {
|
|||||||
* or `null` if highlighting is unavailable or failed (caller keeps plain code).
|
* or `null` if highlighting is unavailable or failed (caller keeps plain code).
|
||||||
*/
|
*/
|
||||||
export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
|
export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
|
||||||
const key = `highlight:${lang}:${code}`;
|
const key = cacheKeyFor('highlight', lang, code);
|
||||||
const cached = resultCache.get(key);
|
const cached = resultCache.get(key);
|
||||||
if (cached?.type === 'highlight') return cached.html;
|
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 }));
|
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
|
||||||
if (response?.type !== 'highlight') return null;
|
if (response?.type !== 'highlight') return null;
|
||||||
const entry: CachedHighlight = { type: 'highlight', html: response.html };
|
const entry: CachedHighlight = { type: 'highlight', html: response.html };
|
||||||
resultCache.set(key, entry);
|
resultCache.set(key, entry, entryBytes(key, entry));
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
return result?.type === 'highlight' ? result.html : null;
|
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.
|
* round-trip instead of one per line. Resolves to `null` on failure.
|
||||||
*/
|
*/
|
||||||
export const highlightLinesInWorker = async (code: string, lang: string): Promise<string[] | null> => {
|
export const highlightLinesInWorker = async (code: string, lang: string): Promise<string[] | null> => {
|
||||||
const key = `highlightLines:${lang}:${code}`;
|
const key = cacheKeyFor('highlightLines', lang, code);
|
||||||
const cached = resultCache.get(key);
|
const cached = resultCache.get(key);
|
||||||
if (cached?.type === 'highlightLines') return cached.lines;
|
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 }));
|
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
||||||
if (response?.type !== 'highlightLines') return null;
|
if (response?.type !== 'highlightLines') return null;
|
||||||
const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
|
const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
|
||||||
resultCache.set(key, entry);
|
resultCache.set(key, entry, entryBytes(key, entry));
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
return result?.type === 'highlightLines' ? result.lines : null;
|
return result?.type === 'highlightLines' ? result.lines : null;
|
||||||
@@ -159,7 +170,7 @@ export const highlightTokensInWorker = async (
|
|||||||
themeName: string,
|
themeName: string,
|
||||||
theme: unknown,
|
theme: unknown,
|
||||||
): Promise<MarkdownTokenRun[][] | null> => {
|
): Promise<MarkdownTokenRun[][] | null> => {
|
||||||
const key = `highlightTokens:${themeName}:${lang}:${code}`;
|
const key = cacheKeyFor('highlightTokens', lang, code, themeName);
|
||||||
const cached = resultCache.get(key);
|
const cached = resultCache.get(key);
|
||||||
if (cached?.type === 'highlightTokens') return cached.lines;
|
if (cached?.type === 'highlightTokens') return cached.lines;
|
||||||
|
|
||||||
@@ -176,7 +187,7 @@ export const highlightTokensInWorker = async (
|
|||||||
if (response?.type !== 'highlightTokens') return null;
|
if (response?.type !== 'highlightTokens') return null;
|
||||||
sentThemes.add(themeName);
|
sentThemes.add(themeName);
|
||||||
const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
|
const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
|
||||||
resultCache.set(key, entry);
|
resultCache.set(key, entry, entryBytes(key, entry));
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
return result?.type === 'highlightTokens' ? result.lines : null;
|
return result?.type === 'highlightTokens' ? result.lines : null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import katex from 'katex';
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { HighlightResultCache, stringPairSize } from './highlightResultCache';
|
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
|
||||||
import { highlightCodeInWorker } from './markdown-worker';
|
import { highlightCodeInWorker } from './markdown-worker';
|
||||||
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||||
|
|
||||||
@@ -254,32 +254,37 @@ const highlightCodeBlocks = async (html: string): Promise<string> => {
|
|||||||
|
|
||||||
const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT;
|
const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT;
|
||||||
|
|
||||||
let result = html;
|
// Highlight all eligible fences concurrently — sequential await was O(n)
|
||||||
for (const match of matches) {
|
// worker round-trips for messages with multiple code blocks.
|
||||||
const [full, rawLang, escapedCode] = match;
|
const replacements = await Promise.all(
|
||||||
const requested = (rawLang || 'text').toLowerCase();
|
matches.map(async (match) => {
|
||||||
// Leave mermaid fences untouched so the decorate pass can render them as
|
const [full, rawLang, escapedCode] = match;
|
||||||
// diagrams (highlighting would strip the `language-mermaid` class).
|
const requested = (rawLang || 'text').toLowerCase();
|
||||||
if (requested === 'mermaid') continue;
|
// 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.
|
// Oversized block: skip highlight, keep plain code but stamp the language.
|
||||||
if (exceedsLineLimit(code, lineLimit)) {
|
if (exceedsLineLimit(code, lineLimit)) {
|
||||||
result = result.replace(full, () => full.replace('<pre', `<pre data-md-lang="${requested}"`));
|
return { full, next: full.replace('<pre', `<pre data-md-lang="${requested}"`) };
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Tokenize off the main thread. On failure the worker resolves to null and
|
// Tokenize off the main thread. On failure the worker resolves to null and
|
||||||
// we keep the original escaped <pre><code> (no main-thread highlight).
|
// we keep the original escaped <pre><code> (no main-thread highlight).
|
||||||
const highlighted = await highlightCodeInWorker(code, requested);
|
const highlighted = await highlightCodeInWorker(code, requested);
|
||||||
if (highlighted) {
|
if (!highlighted) return null;
|
||||||
// Stamp the language so the decorate pass can show a header label.
|
// Stamp the language so the decorate pass can show a header label.
|
||||||
const stamped = highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`);
|
return { full, next: highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`) };
|
||||||
result = result.replace(full, () => stamped);
|
}),
|
||||||
}
|
);
|
||||||
}
|
|
||||||
|
|
||||||
|
let result = html;
|
||||||
|
for (const replacement of replacements) {
|
||||||
|
if (!replacement) continue;
|
||||||
|
result = result.replace(replacement.full, () => replacement.next);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -331,20 +336,10 @@ const sanitize = (html: string): string => {
|
|||||||
const HTML_CACHE_MAX_ENTRIES = 2000;
|
const HTML_CACHE_MAX_ENTRIES = 2000;
|
||||||
const HTML_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
const HTML_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
||||||
|
|
||||||
const htmlCache = new HighlightResultCache<string>(
|
const htmlCache = new HighlightResultCache<string>({
|
||||||
{ maxEntries: HTML_CACHE_MAX_ENTRIES, maxBytes: HTML_CACHE_MAX_BYTES },
|
maxEntries: HTML_CACHE_MAX_ENTRIES,
|
||||||
stringPairSize,
|
maxBytes: HTML_CACHE_MAX_BYTES,
|
||||||
);
|
});
|
||||||
|
|
||||||
// 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Content-addressed cache key for a markdown block (exported for tests). */
|
/** Content-addressed cache key for a markdown block (exported for tests). */
|
||||||
export const markdownBlockCacheKey = (
|
export const markdownBlockCacheKey = (
|
||||||
@@ -411,14 +406,14 @@ export const renderMarkdownBlocks = async (
|
|||||||
const blocks = streamBlocks(text, streaming);
|
const blocks = streamBlocks(text, streaming);
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
blocks.map(async (block) => {
|
blocks.map(async (block) => {
|
||||||
const contentHash = hash(block.raw);
|
const contentHash = contentFingerprint(block.raw);
|
||||||
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight);
|
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight);
|
||||||
const cached = htmlCache.get(id);
|
const cached = htmlCache.get(id);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return { id, html: cached };
|
return { id, html: cached };
|
||||||
}
|
}
|
||||||
const html = await parseBlock(block);
|
const html = await parseBlock(block);
|
||||||
htmlCache.set(id, html);
|
htmlCache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
|
||||||
return { id, html };
|
return { id, html };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,16 +8,27 @@
|
|||||||
* 3. Worker/client had no result memoization.
|
* 3. Worker/client had no result memoization.
|
||||||
*
|
*
|
||||||
* These tests assert the fixed contracts: content-addressed caching, room for
|
* 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 { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import { HighlightResultCache, stringPairSize } from './highlightResultCache';
|
import {
|
||||||
|
contentFingerprint,
|
||||||
|
estimateTokenRunsBytes,
|
||||||
|
HighlightResultCache,
|
||||||
|
utf16Bytes,
|
||||||
|
} from './highlightResultCache';
|
||||||
|
|
||||||
let highlightCalls = 0;
|
let highlightCalls = 0;
|
||||||
|
let highlightInflight = 0;
|
||||||
|
let highlightMaxInflight = 0;
|
||||||
|
|
||||||
const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
|
const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
|
||||||
highlightCalls += 1;
|
highlightCalls += 1;
|
||||||
|
highlightInflight += 1;
|
||||||
|
highlightMaxInflight = Math.max(highlightMaxInflight, highlightInflight);
|
||||||
|
await Promise.resolve();
|
||||||
|
highlightInflight -= 1;
|
||||||
return `<pre data-lang="${lang}"><code>${code}</code></pre>`;
|
return `<pre data-lang="${lang}"><code>${code}</code></pre>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,35 +51,45 @@ beforeEach(() => {
|
|||||||
resetMarkdownHtmlCacheForTests();
|
resetMarkdownHtmlCacheForTests();
|
||||||
resetMarkdownWorkerClientCacheForTests();
|
resetMarkdownWorkerClientCacheForTests();
|
||||||
highlightCalls = 0;
|
highlightCalls = 0;
|
||||||
|
highlightInflight = 0;
|
||||||
|
highlightMaxInflight = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('HighlightResultCache', () => {
|
describe('HighlightResultCache', () => {
|
||||||
test('returns cached values for identical keys and refreshes LRU order', () => {
|
test('returns cached values for identical keys and refreshes LRU order', () => {
|
||||||
const cache = new HighlightResultCache<string>(
|
const cache = new HighlightResultCache<string>({ maxEntries: 2, maxBytes: 10_000 });
|
||||||
{ maxEntries: 2, maxBytes: 10_000 },
|
cache.set('a', 'one', utf16Bytes('a') + utf16Bytes('one'));
|
||||||
stringPairSize,
|
cache.set('b', 'two', utf16Bytes('b') + utf16Bytes('two'));
|
||||||
);
|
|
||||||
cache.set('a', 'one');
|
|
||||||
cache.set('b', 'two');
|
|
||||||
expect(cache.get('a')).toBe('one');
|
expect(cache.get('a')).toBe('one');
|
||||||
// Touch `a` so `b` is oldest; inserting `c` should evict `b`.
|
// 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('b')).toEqual(undefined);
|
||||||
expect(cache.get('a')).toBe('one');
|
expect(cache.get('a')).toBe('one');
|
||||||
expect(cache.get('c')).toBe('three');
|
expect(cache.get('c')).toBe('three');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('evicts by byte budget while still caching a single oversized entry', () => {
|
test('evicts by byte budget while still caching a single oversized entry', () => {
|
||||||
const cache = new HighlightResultCache<string>(
|
const cache = new HighlightResultCache<string>({ maxEntries: 10, maxBytes: 64 });
|
||||||
{ maxEntries: 10, maxBytes: 64 },
|
cache.set('small', 'x', utf16Bytes('small') + utf16Bytes('x'));
|
||||||
stringPairSize,
|
cache.set('huge', 'y'.repeat(200), utf16Bytes('huge') + utf16Bytes('y'.repeat(200)));
|
||||||
);
|
|
||||||
cache.set('small', 'x');
|
|
||||||
cache.set('huge', 'y'.repeat(200));
|
|
||||||
expect(cache.get('huge')).toBe('y'.repeat(200));
|
expect(cache.get('huge')).toBe('y'.repeat(200));
|
||||||
// Oversized insert cleared prior entries to make room.
|
// Oversized insert cleared prior entries to make room.
|
||||||
expect(cache.size).toBe(1);
|
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<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)', () => {
|
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', 'live', false)).toBe('abc:live:0');
|
||||||
expect(markdownBlockCacheKey('abc', 'full', true)).not.toBe(markdownBlockCacheKey('abc', 'full', false));
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user