Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/web-ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ establish visual fidelity):
pnpm --dir src/web-ui run test:run src/infrastructure/update src/infrastructure/api/service-api/SystemAPI.test.ts src/shared/notification-system/components/NotificationContainer.test.tsx
```

For Markdown parse prewarming or dependency resolution, run
`pnpm --dir src/web-ui run test:run src/infrastructure/markdown/markdownParse.worker.test.ts src/infrastructure/markdown/markdownParseCache.test.ts`.
This builds the real Worker entry with the product aliases, checks parsing without
a DOM, and covers failure fallback. Build configuration changes also need `pnpm run build:web`.

For shared overlay ordering, dismissal, focus or presence changes, run the
focused DOM and ownership contracts in addition to `check:web` (these do not
establish visual or remote transport behavior):
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@xterm/addon-webgl": "^0.20.0-beta.100",
"@xterm/headless": "6.1.0-beta.141",
"@xterm/xterm": "^6.1.0-beta.101",
"decode-named-character-reference": "1.3.0",
"diff": "^8.0.3",
"highlight.js": "^11.11.1",
"i18next": "^25.8.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @vitest-environment node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
import { build, resolveConfig } from 'vite';
import { expect, it } from 'vitest';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';

const configFile = fileURLToPath(new URL('../../../vite.config.ts', import.meta.url));
const root = path.dirname(configFile);
const entry = fileURLToPath(new URL('./markdownParseCache.ts', import.meta.url));

it('builds a Markdown worker that initializes and parses GFM without a DOM', async () => {
const config = await resolveConfig({ root, configFile, logLevel: 'silent' }, 'build');
// Use the product resolver and real new Worker URL, without unrelated app/font builds.
const result = await build({
root,
configFile: false,
publicDir: false,
logLevel: 'silent',
resolve: { alias: config.resolve.alias },
build: {
write: false,
minify: true,
rollupOptions: { input: entry, preserveEntrySignatures: 'strict' },
},
});
if ('on' in result) throw new Error('Unexpected watch build');
const output = (Array.isArray(result) ? result : [result]).flatMap(bundle => bundle.output);
const worker = output.find(asset => asset.type === 'asset' && /markdownParse\.worker-.*\.js$/.test(asset.fileName));
if (!worker || worker.type !== 'asset') throw new Error('Markdown worker asset was not emitted');
const replies: unknown[] = [];
const self = {
postMessage: (message: unknown) => replies.push(message),
onmessage: undefined as undefined | ((event: { data: string }) => void),
};
runInNewContext(String(worker.source), { self }, { timeout: 2000 });
expect(self.onmessage).toBeTypeOf('function');
for (const source of [
'# Entities © & ≂̸ 🚀 &unknown;\n\n[link][target]\n\n[target]: https://example.com',
'| A | B |\n| - | - |\n| ~~x~~ | y |\n\n- [x] done\n\nNote[^1]\n\n[^1]: Footnote',
]) {
self.onmessage!({ data: source });
expect(replies.pop()).toEqual({ tree: unified().use(remarkParse).use(remarkGfm).parse(source) });
}
}, 20_000);

it('resolves the DOM-free decoder in development too', async () => {
const config = await resolveConfig({ root, configFile, logLevel: 'silent' }, 'serve');
const resolved = await config.createResolver()('decode-named-character-reference', entry);
expect(resolved).toMatch(/\/decode-named-character-reference\/index\.js$/);
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
Expand Down Expand Up @@ -39,3 +39,89 @@ describe('immutable Markdown parse cache', () => {
expect(JSON.stringify(first)).not.toContain('onError');
});
});

describe('Markdown worker prewarming', () => {
const text = 'Paragraph with © and **emphasis**.\n\n'.repeat(80);
let workers: TestWorker[];
class TestWorker {
onerror?: () => void;
onmessage?: (event: { data: { tree?: unknown } }) => void;
postMessage = vi.fn();
terminate = vi.fn();
constructor() { workers.push(this); }
}

beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
workers = [];
vi.stubGlobal('Worker', TestWorker);
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('stops restarting a failed worker and preserves main-thread parsing', async () => {
const { prewarmMarkdownParse, remarkCachedParse: cachedParse } = await import('./markdownParseCache');
prewarmMarkdownParse(text);
prewarmMarkdownParse(`${text} queued`);
workers[0].onerror!();
prewarmMarkdownParse(`${text} streamed update`);
vi.advanceTimersByTime(60_000);
prewarmMarkdownParse(`${text} later update`);
expect(workers).toHaveLength(1);
expect(workers[0].terminate).toHaveBeenCalledOnce();
expect(workers[0].postMessage).toHaveBeenCalledTimes(1);
const processor = unified().use(remarkParse).use(remarkGfm).use(cachedParse);
expect(processor.parse(text)).toEqual(unified().use(remarkParse).use(remarkGfm).parse(text));
});

it('does not retry a worker whose constructor throws', async () => {
const constructor = vi.fn(function () { throw new Error('Worker unavailable'); });
vi.stubGlobal('Worker', constructor);
const { prewarmMarkdownParse } = await import('./markdownParseCache');
expect(() => prewarmMarkdownParse(text)).not.toThrow();
prewarmMarkdownParse(`${text} update`);
expect(constructor).toHaveBeenCalledOnce();
});

it('disables and terminates the worker if posting fails', async () => {
const { prewarmMarkdownParse } = await import('./markdownParseCache');
prewarmMarkdownParse(text);
workers[0].onmessage!({ data: {} });
workers[0].postMessage.mockImplementation(() => { throw new Error('Cannot post'); });
expect(() => prewarmMarkdownParse(`${text} next`)).not.toThrow();
prewarmMarkdownParse(`${text} update`);
expect(workers).toHaveLength(1);
expect(workers[0].terminate).toHaveBeenCalledOnce();
expect(workers[0].postMessage).toHaveBeenCalledTimes(2);
});

it('caches successful replies and can restart after normal idle termination', async () => {
const { prewarmMarkdownParse, remarkCachedParse: cachedParse } = await import('./markdownParseCache');
const tree = unified().use(remarkParse).use(remarkGfm).parse(text);
prewarmMarkdownParse(text);
workers[0].onmessage!({ data: { tree } });
const processor = unified().use(remarkParse).use(remarkGfm).use(cachedParse);
expect(processor.parse(text)).toEqual(tree);
prewarmMarkdownParse(text);
expect(workers[0].postMessage).toHaveBeenCalledOnce();
vi.advanceTimersByTime(30_000);
expect(workers[0].terminate).toHaveBeenCalledOnce();
prewarmMarkdownParse(`${text} next`);
expect(workers).toHaveLength(2);
});

it('handles posting failures while advancing the queue after a reply', async () => {
const { prewarmMarkdownParse } = await import('./markdownParseCache');
prewarmMarkdownParse(text);
prewarmMarkdownParse(`${text} queued`);
workers[0].postMessage.mockImplementation(() => { throw new Error('Cannot post'); });
expect(() => workers[0].onmessage!({ data: {} })).not.toThrow();
prewarmMarkdownParse(`${text} update`);
expect(workers).toHaveLength(1);
expect(workers[0].terminate).toHaveBeenCalledOnce();
});
});
20 changes: 16 additions & 4 deletions src/web-ui/src/infrastructure/markdown/markdownParseCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ const jobs = new Map<string, number>();
let worker: Worker | undefined;
let current: string | undefined;
let idle: ReturnType<typeof setTimeout> | undefined;
let workerFailed = false;

function disableWorker() {
// Prewarming is optional; parsing on the main thread remains available.
// A broken worker must not be recreated for every streamed content update.
workerFailed = true;
clearTimeout(idle);
worker?.terminate();
worker = undefined;
current = undefined;
jobs.clear();
}

const cacheTree = (text: string, tree: Root) => cache.set(text, tree, text.length * 14 + 256);

Expand Down Expand Up @@ -38,20 +50,20 @@ function pump() {
if (event.data.tree && jobs.has(current)) cacheTree(current, event.data.tree);
jobs.delete(current); current = undefined;
}
pump();
try { pump(); } catch { disableWorker(); }
};
worker.onerror = () => { worker?.terminate(); worker = undefined; current = undefined; jobs.clear(); };
worker.onerror = disableWorker;
}
current = next;
worker.postMessage(next);
}

/** Optional, cancellable warmup of immutable text. No view waits for this queue. */
export function prewarmMarkdownParse(text: string): () => void {
if (text.length < 2000 || text.length > 400_000 || cache.has(text) || typeof Worker === 'undefined') return () => {};
if (workerFailed || text.length < 2000 || text.length > 400_000 || cache.has(text) || typeof Worker === 'undefined') return () => {};
if (!jobs.has(text) && jobs.size >= 16) return () => {};
jobs.set(text, (jobs.get(text) ?? 0) + 1);
try { pump(); } catch { jobs.clear(); current = undefined; worker?.terminate(); worker = undefined; }
try { pump(); } catch { disableWorker(); }
return () => {
const count = jobs.get(text) ?? 0;
if (count <= 1) jobs.delete(text); else jobs.set(text, count - 1);
Expand Down
6 changes: 6 additions & 0 deletions src/web-ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ export default defineConfig(({ mode, command }) => {
resolve: {
dedupe: ['react', 'react-dom', 'lucide-react'],
alias: [
// The browser export uses document at module load and crashes Markdown workers.
// Use the package's portable decoder in both the page and its workers.
{
find: /^decode-named-character-reference$/,
replacement: requireFromWebUi.resolve('decode-named-character-reference'),
},
// @xterm/headless 6.1.0-beta.141 advertises a missing lib/xterm.mjs.
// Resolve its published ESM entry for both dev optimization and builds.
{
Expand Down
Loading