fix(ui): correct markdown cache identity, streaming churn, and redundant tiers
Review follow-up on the #2769 highlight caches. Fingerprint strength. The block/highlight caches are now global and content-addressed, so a hash collision no longer mis-colors a block — it returns a *different* block's rendered HTML and shows the user source they never wrote. Length + one 32-bit FNV-1a is not enough key space for that failure mode at session scale. `contentFingerprint` now combines two independent 32-bit multiplicative hashes with a final avalanche (~64 bits); two multiplies per character are free next to Shiki tokenization. Streaming churn. Content addressing made every streaming step of the trailing `live` block insert a new cache entry, so one long message evicted the settled `full` blocks the fix exists to keep warm. `full` and `live` blocks now use separate caches; the live cache is small (32 entries / 2MB) because it only has to absorb repeat renders of the same step. Redundant worker-side caches. `markdown-worker.ts` is the only sender to the Shiki worker, and its client cache is larger than the worker-side ones, so the worker caches could not serve a hit the client had not already served — they only duplicated up to 48MB of payloads in a second heap. Removed; the reason memoization belongs on the client is now documented there, along with why only `highlightTokens` carries a theme in its key. Dead `cacheKey` plumbing. `renderMarkdownBlocks` kept a `cacheKey` parameter it only `void`-ed. Removed it and the now-unused `useMorphdomMarkdown` prop; the remaining call-site local is renamed `fadeKey` for what it actually keys. Tests: image-mode cache identity, streaming-does-not-evict-settled-blocks, live-cache reuse, and a 20k same-length-source fingerprint collision check. Each new guard was verified to fail without its fix.
This commit is contained in:
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
|
||||
## [Unreleased]
|
||||
|
||||
- **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders.
|
||||
- **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech).
|
||||
- Usage/Claude: Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes.
|
||||
- Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech).
|
||||
- Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting.
|
||||
|
||||
@@ -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,9 +1135,6 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
containerRef,
|
||||
text: renderedContent,
|
||||
streaming: false,
|
||||
// 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,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Bounded LRU for Shiki highlight results.
|
||||
// Bounded LRU for rendered markdown / Shiki highlight results.
|
||||
//
|
||||
// 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.
|
||||
// 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;
|
||||
@@ -19,18 +19,36 @@ type CacheEntry<T> = {
|
||||
/** 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 + 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).
|
||||
* 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 h = 0x811c9dc5;
|
||||
let h1 = 0x811c9dc5;
|
||||
let h2 = 0xc2b2ae35;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
h ^= value.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
const code = value.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ code, 0x01000193);
|
||||
h2 = Math.imul(h2 ^ code, 0x27220a95);
|
||||
}
|
||||
return `${value.length.toString(36)}_${(h >>> 0).toString(36)}`;
|
||||
return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`;
|
||||
};
|
||||
|
||||
/** Approximate byte cost of token-run lines without JSON.stringify. */
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki';
|
||||
import {
|
||||
contentFingerprint,
|
||||
estimateTokenRunsBytes,
|
||||
HighlightResultCache,
|
||||
utf16Bytes,
|
||||
} from './highlightResultCache';
|
||||
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
|
||||
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
||||
import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
||||
|
||||
// Shiki FontStyle bitmask (from @shikijs/types). Inlined to avoid an extra import.
|
||||
const FONT_STYLE_ITALIC = 1;
|
||||
@@ -38,25 +32,6 @@ let highlighter: ReturnType<typeof createHighlighter> | undefined;
|
||||
// Serialize work so language loading / tokenization never overlaps.
|
||||
let queue = Promise.resolve();
|
||||
|
||||
// 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<string>({
|
||||
maxEntries: WORKER_CACHE_MAX_ENTRIES,
|
||||
maxBytes: WORKER_CACHE_MAX_BYTES,
|
||||
});
|
||||
const linesCache = new HighlightResultCache<string[]>({
|
||||
maxEntries: WORKER_CACHE_MAX_ENTRIES,
|
||||
maxBytes: WORKER_CACHE_MAX_BYTES,
|
||||
});
|
||||
const tokensCache = new HighlightResultCache<MarkdownTokenRun[][]>({
|
||||
maxEntries: WORKER_CACHE_MAX_ENTRIES,
|
||||
maxBytes: WORKER_CACHE_MAX_BYTES,
|
||||
});
|
||||
|
||||
const ensureHighlighter = (): ReturnType<typeof createHighlighter> => {
|
||||
highlighter ??= createHighlighter({
|
||||
// Cast: the theme is a CSS-variable TextMate theme; Shiki accepts the shape.
|
||||
@@ -97,19 +72,8 @@ const resolveLanguage = async (instance: Instance, requested: string): Promise<s
|
||||
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> {
|
||||
try {
|
||||
const cacheKey = htmlKey(request.lang, request.code);
|
||||
const cached = htmlCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
post({ type: 'highlight', id: request.id, html: cached });
|
||||
return;
|
||||
}
|
||||
const instance = await ensureHighlighter();
|
||||
const lang = await resolveLanguage(instance, request.lang);
|
||||
const html = instance.codeToHtml(request.code, {
|
||||
@@ -117,7 +81,6 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highli
|
||||
theme: MARKDOWN_SHIKI_THEME,
|
||||
tabindex: false,
|
||||
});
|
||||
htmlCache.set(cacheKey, html, utf16Bytes(cacheKey) + utf16Bytes(html));
|
||||
post({ type: 'highlight', id: request.id, html });
|
||||
} catch (error) {
|
||||
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
|
||||
@@ -126,12 +89,6 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highli
|
||||
|
||||
async function highlightTokens(request: Extract<MarkdownWorkerRequest, { type: 'highlightTokens' }>): Promise<void> {
|
||||
try {
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
const instance = await ensureHighlighter();
|
||||
if (request.theme && !instance.getLoadedThemes().includes(request.themeName)) {
|
||||
// Cast: a resolved TextMate theme object from the app theme registry.
|
||||
@@ -145,7 +102,6 @@ async function highlightTokens(request: Extract<MarkdownWorkerRequest, { type: '
|
||||
const lines = tokens.map((line) =>
|
||||
line.map((token) => [token.content.length, token.color ?? '', token.fontStyle ?? 0] as [number, string, number]),
|
||||
);
|
||||
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) });
|
||||
@@ -154,12 +110,6 @@ async function highlightTokens(request: Extract<MarkdownWorkerRequest, { type: '
|
||||
|
||||
async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'highlightLines' }>): Promise<void> {
|
||||
try {
|
||||
const cacheKey = linesKey(request.lang, request.code);
|
||||
const cached = linesCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
post({ type: 'highlightLines', id: request.id, lines: cached });
|
||||
return;
|
||||
}
|
||||
const instance = await ensureHighlighter();
|
||||
const lang = await resolveLanguage(instance, request.lang);
|
||||
const { tokens } = instance.codeToTokens(request.code, {
|
||||
@@ -167,9 +117,6 @@ async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'h
|
||||
theme: MARKDOWN_SHIKI_THEME,
|
||||
});
|
||||
const lines = tokens.map((line) => line.map(tokenSpan).join(''));
|
||||
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) });
|
||||
|
||||
@@ -18,6 +18,15 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse }
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -492,34 +492,58 @@ const sanitize = (html: string): string => {
|
||||
// Per-block HTML cache (content-addressed LRU)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Keyed by content hash + mode + highlight flag — 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.
|
||||
// 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 HTML_CACHE_MAX_ENTRIES = 2000;
|
||||
const HTML_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
||||
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;
|
||||
|
||||
const htmlCache = new HighlightResultCache<string>({
|
||||
maxEntries: HTML_CACHE_MAX_ENTRIES,
|
||||
maxBytes: HTML_CACHE_MAX_BYTES,
|
||||
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,
|
||||
});
|
||||
|
||||
/** Content-addressed cache key for a markdown block (exported for tests). */
|
||||
export const markdownBlockCacheKey = (
|
||||
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 cache between cases. */
|
||||
/** Test-only: clear the render HTML caches between cases. */
|
||||
export const resetMarkdownHtmlCacheForTests = (): void => {
|
||||
htmlCache.clear();
|
||||
fullBlockCache.clear();
|
||||
liveBlockCache.clear();
|
||||
};
|
||||
|
||||
/** 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;
|
||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||
@@ -559,18 +583,14 @@ export type RenderedBlock = {
|
||||
* blocks (instead of one joined string) lets the renderer re-morph only the
|
||||
* block that changed, keeping per-step streaming cost ~O(last block).
|
||||
*
|
||||
* `cacheKey` is retained for call-site compatibility / debugging; the HTML
|
||||
* cache itself is content-addressed so distinct renderers with identical
|
||||
* blocks share results and cannot evict each other by identity collision.
|
||||
* 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[]> => {
|
||||
// Retained for call-site compatibility / debugging; lookup is content-addressed.
|
||||
void cacheKey;
|
||||
if (!text) return [];
|
||||
|
||||
const blocks = streamBlocks(text, streaming);
|
||||
@@ -578,12 +598,13 @@ export const renderMarkdownBlocks = async (
|
||||
blocks.map(async (block) => {
|
||||
const contentHash = contentFingerprint(block.raw);
|
||||
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
|
||||
const cached = htmlCache.get(id);
|
||||
const cache = cacheForMode(block.mode);
|
||||
const cached = cache.get(id);
|
||||
if (cached !== undefined) {
|
||||
return { id, html: cached };
|
||||
}
|
||||
const html = await parseBlock(block, imageMode);
|
||||
htmlCache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
|
||||
cache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
|
||||
return { id, html };
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ mock.module('./markdown-worker', () => ({
|
||||
const {
|
||||
renderMarkdownBlocks,
|
||||
resetMarkdownHtmlCacheForTests,
|
||||
markdownBlockCacheKey,
|
||||
__markdownBlockCacheSizesForTests,
|
||||
} = await import('./markdownCore');
|
||||
|
||||
const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
|
||||
@@ -83,6 +83,22 @@ describe('HighlightResultCache', () => {
|
||||
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]],
|
||||
@@ -93,21 +109,22 @@ describe('HighlightResultCache', () => {
|
||||
});
|
||||
|
||||
describe('markdownCore content-addressed htmlCache (#2769)', () => {
|
||||
test('two same-variant SimpleMarkdown-style keys do not re-highlight unchanged content', async () => {
|
||||
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, 'simple:tool');
|
||||
await renderMarkdownBlocks(toolOutputB, false, 'simple:tool');
|
||||
await renderMarkdownBlocks(toolOutputA, false);
|
||||
await renderMarkdownBlocks(toolOutputB, false);
|
||||
const coldCalls = highlightCalls;
|
||||
expect(coldCalls).toBeGreaterThan(0);
|
||||
|
||||
// 100 more passes with the legacy shared `simple:tool` identity keys —
|
||||
// must not produce additional worker calls.
|
||||
// 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, 'simple:tool');
|
||||
await renderMarkdownBlocks(toolOutputB, false, 'simple:tool');
|
||||
await renderMarkdownBlocks(toolOutputA, false);
|
||||
await renderMarkdownBlocks(toolOutputB, false);
|
||||
}
|
||||
|
||||
expect(highlightCalls).toBe(coldCalls);
|
||||
@@ -115,19 +132,18 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => {
|
||||
|
||||
test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => {
|
||||
const parts = Array.from({ length: 600 }, (_, i) => ({
|
||||
key: `markdown-part-part_${i}`,
|
||||
content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``,
|
||||
}));
|
||||
|
||||
for (const part of parts) {
|
||||
await renderMarkdownBlocks(part.content, false, part.key);
|
||||
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, part.key);
|
||||
await renderMarkdownBlocks(part.content, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,24 +155,74 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => {
|
||||
const stable = '```ts\nconst stable = true;\n```';
|
||||
const changing = '```ts\nconst n = 1;\n```';
|
||||
|
||||
await renderMarkdownBlocks(stable, false, 'a');
|
||||
await renderMarkdownBlocks(changing, false, 'b');
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
await renderMarkdownBlocks(changing, false);
|
||||
const afterFirst = highlightCalls;
|
||||
|
||||
await renderMarkdownBlocks(stable, false, 'a');
|
||||
await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false, 'b');
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false);
|
||||
expect(highlightCalls).toBe(afterFirst + 1);
|
||||
|
||||
await renderMarkdownBlocks(stable, false, 'a');
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
expect(highlightCalls).toBe(afterFirst + 1);
|
||||
});
|
||||
|
||||
test('block cache keys are content-addressed (mode + highlight + imageMode + hash)', () => {
|
||||
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).toBe('abc:full:1:inline');
|
||||
expect(markdownBlockCacheKey('abc', 'live', false, 'inline')).toBe('abc:live:0:inline');
|
||||
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', false, 'inline'));
|
||||
// Image mode changes the rendered HTML, so it must not share a cache entry.
|
||||
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', true, 'label'));
|
||||
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 () => {
|
||||
@@ -168,7 +234,7 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => {
|
||||
'```ts\nconst c = 3;\n```',
|
||||
].join('\n');
|
||||
|
||||
await renderMarkdownBlocks(multi, false, 'multi');
|
||||
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