返回 DeepSeek-Reasonix
markdownWorkerClient.ts
根目录 / desktop / frontend / src / lib / markdownWorkerClient.ts
1 // markdownWorkerClient — request/response client for markdown.worker.ts, the
2 // off-main-thread Markdown parse (Phase E). Follows the regexSearchClient
3 // precedent (`?worker&inline`, lazy spawn) with a few additions the transcript
4 // needs:
5 //
6 // - One parse at a time. Cancelling the active request terminates the worker,
7 // so a stale giant document cannot keep consuming CPU after a tab switch.
8 // - Cancellation resolves with `undefined` (never rejects), so unmounted
9 // rows and superseded generations settle quietly.
10 // - When Worker is unavailable (jsdom/tests) or the inline chunk fails to
11 // load, parsing falls back to the isomorphic in-process pipeline.
12 // - dispose() terminates the worker and settles every pending request.
13 // The app-level singleton is lease-counted: Transcript instances acquire
14 // on mount and release on unmount, and the worker terminates when the
15 // last lease goes away so a closed session set never leaks the thread.
16 //
17 // Only lazy markdown chunks may import this module: its fallback path pulls in
18 // the full parse pipeline (remark + katex), which must stay out of the shell.
19
20 // Only type imports from the pipeline: the fallback parser is loaded on demand
21 // so this module stays light enough for the eager transcript graph, and the
22 // remark+katex stack only ever lands in lazy chunks / the inline worker.
23
24 import type { MarkdownParseResult } from "./markdownPipeline";
25 import { registerMarkdownWorkerDiagnostics } from "./sessionDiagnostics";
26 import {
27 markdownPriorityRank,
28 type MarkdownDocumentRequest,
29 type MarkdownParseRequest,
30 type MarkdownParseResponse,
31 type MarkdownWorkerPriority,
32 type MarkdownWorkerRequest,
33 } from "./markdownWorkerProtocol";
34
35 export type { MarkdownParseRequest, MarkdownParseResponse, MarkdownWorkerPriority } from "./markdownWorkerProtocol";
36
37 type MarkdownPipelineModule = typeof import("./markdownPipeline");
38 let pipelinePromise: Promise<MarkdownPipelineModule> | null = null;
39 function loadPipeline(): Promise<MarkdownPipelineModule> {
40 if (!pipelinePromise) pipelinePromise = import("./markdownPipeline");
41 return pipelinePromise;
42 }
43
44 export interface MarkdownWorkerLike {
45 onmessage: ((event: MessageEvent<MarkdownParseResponse>) => void) | null;
46 onerror: ((event: ErrorEvent) => void) | null;
47 postMessage(request: MarkdownWorkerRequest): void;
48 terminate(): void;
49 }
50
51 export interface MarkdownParseHandle {
52 /** Resolves the render blocks and selection projection together. */
53 promise: Promise<MarkdownParseResult | undefined>;
54 /** Drop the response when it arrives; resolves the promise with undefined. */
55 cancel(): void;
56 }
57
58 export interface MarkdownWorkerClientOptions {
59 /** Override worker creation (tests inject a synchronous fake). */
60 createWorker?: () => Promise<MarkdownWorkerLike>;
61 /** Override the in-process fallback parse (tests inject a spy). */
62 parseInProcess?: (text: string) => MarkdownParseResult;
63 }
64
65 interface PendingRequest {
66 resolve(result: MarkdownParseResult | undefined): void;
67 reject(error: Error): void;
68 /** performance.now() at parse() time, for parse-latency diagnostics. */
69 startedAt: number;
70 text: string;
71 state: "queued" | "worker" | "fallback";
72 priority: number;
73 documentId?: string;
74 final?: boolean;
75 cancelled?: boolean;
76 }
77
78 interface WorkerDocumentState {
79 sentText: string | null;
80 }
81
82 function nowMs(): number {
83 return typeof performance !== "undefined" ? performance.now() : Date.now();
84 }
85
86 export class MarkdownWorkerClient {
87 private readonly createWorker?: () => Promise<MarkdownWorkerLike>;
88 private readonly parseInProcess?: (text: string) => MarkdownParseResult;
89 private worker: MarkdownWorkerLike | null = null;
90 private workerPromise: Promise<MarkdownWorkerLike | null> | null = null;
91 private readonly pending = new Map<number, PendingRequest>();
92 private readonly documents = new Map<string, WorkerDocumentState>();
93 private activeRequestId: number | null = null;
94 private pumping = false;
95 private nextId = 1;
96 private disposed = false;
97 // Content-free diagnostics (sessionDiagnostics / crash perf context).
98 private completedParses = 0;
99 private totalParseMs = 0;
100 private maxParseMs = 0;
101 private fallbackActive = false;
102 private workerFailures = 0;
103 /** Test/diagnostic introspection: in-flight request count. */
104 get pendingCount(): number {
105 return this.pending.size;
106 }
107
108 /** Parse-pipeline counters for the diagnostics snapshot. */
109 stats() {
110 return {
111 pending: this.pending.size,
112 completed: this.completedParses,
113 avgParseMs: this.completedParses > 0 ? this.totalParseMs / this.completedParses : 0,
114 maxParseMs: this.maxParseMs,
115 fallbackActive: this.fallbackActive,
116 workerFailures: this.workerFailures,
117 };
118 }
119
120 constructor(options: MarkdownWorkerClientOptions = {}) {
121 this.createWorker = options.createWorker;
122 this.parseInProcess = options.parseInProcess;
123 }
124
125 parse(text: string): MarkdownParseHandle {
126 return this.enqueue(text, "visible");
127 }
128
129 /**
130 * Parse a retained worker document. Prefix growth crosses the bridge as an
131 * append; replacements and finalization carry an authoritative snapshot.
132 * Superseding an active document parse drops its response without killing
133 * the worker, so a live stream does not churn parser threads.
134 */
135 parseDocument(documentId: string, text: string, options: {
136 final?: boolean;
137 priority?: MarkdownWorkerPriority;
138 } = {}): MarkdownParseHandle {
139 if (!documentId) return this.parse(text);
140 if (!this.documents.has(documentId)) this.documents.set(documentId, { sentText: null });
141 return this.enqueue(text, options.priority ?? "visible", documentId, options.final ?? false);
142 }
143
144 private enqueue(
145 text: string,
146 priority: MarkdownWorkerPriority,
147 documentId?: string,
148 final = false,
149 ): MarkdownParseHandle {
150 if (this.disposed) {
151 return { promise: Promise.resolve(undefined), cancel: () => {} };
152 }
153 const id = this.nextId;
154 this.nextId += 1;
155 const promise = new Promise<MarkdownParseResult | undefined>((resolve, reject) => {
156 this.pending.set(id, {
157 resolve, reject, startedAt: nowMs(), text, state: "queued",
158 priority: markdownPriorityRank(priority), documentId, final,
159 });
160 });
161 const cancel = () => {
162 const entry = this.pending.get(id);
163 if (!entry) return;
164 entry.resolve(undefined);
165 if (this.activeRequestId === id && entry.documentId) {
166 // A worker parse is synchronous once dispatched. Retain only its
167 // bookkeeping until the response arrives, then schedule the newest
168 // document snapshot without terminating the shared worker.
169 entry.cancelled = true;
170 return;
171 }
172 this.pending.delete(id);
173 if (this.activeRequestId !== id) return;
174 if (entry.state === "fallback") {
175 // Synchronous fallback work cannot be interrupted. Keep the queue
176 // parked until its promise settles instead of starting a second parse.
177 return;
178 }
179 this.activeRequestId = null;
180 this.resetWorker();
181 void this.pump();
182 };
183 void this.pump();
184 return { promise, cancel };
185 }
186
187 /** Release worker-side source/AST ownership when its row unmounts. */
188 releaseDocument(documentId: string): void {
189 if (!this.documents.delete(documentId)) return;
190 for (const [id, entry] of this.pending) {
191 if (entry.documentId !== documentId) continue;
192 entry.resolve(undefined);
193 if (this.activeRequestId === id) entry.cancelled = true;
194 else this.pending.delete(id);
195 }
196 if (this.worker) {
197 this.worker.postMessage({ id: 0, op: "release", documentId });
198 }
199 }
200
201 private async pump(): Promise<void> {
202 if (this.disposed || this.activeRequestId !== null || this.pumping) return;
203 this.pumping = true;
204 try {
205 const next = Array.from(this.pending.entries())
206 .filter(([, entry]) => entry.state === "queued" && !entry.cancelled)
207 .sort((left, right) => left[1].priority - right[1].priority || left[0] - right[0])[0];
208 if (!next) return;
209 const [id, entry] = next;
210 const worker = typeof Worker === "undefined" ? null : await this.ensureWorker();
211 if (this.disposed || !this.pending.has(id) || this.activeRequestId !== null) return;
212 this.activeRequestId = id;
213 if (!worker) {
214 entry.state = "fallback";
215 this.parseInProcessAsync(id, entry.text);
216 return;
217 }
218 entry.state = "worker";
219 worker.postMessage(this.workerRequest(id, entry));
220 } finally {
221 this.pumping = false;
222 if (
223 !this.disposed
224 && this.activeRequestId === null
225 && Array.from(this.pending.values()).some((entry) => entry.state === "queued")
226 ) {
227 queueMicrotask(() => void this.pump());
228 }
229 }
230 }
231
232 private workerRequest(id: number, entry: PendingRequest): MarkdownWorkerRequest {
233 if (!entry.documentId) return { id, text: entry.text } satisfies MarkdownParseRequest;
234 const document = this.documents.get(entry.documentId);
235 const sentText = document?.sentText ?? null;
236 let request: MarkdownDocumentRequest;
237 if (entry.final) {
238 request = { id, op: "finalize", documentId: entry.documentId, text: entry.text };
239 } else if (sentText === null) {
240 request = { id, op: "open", documentId: entry.documentId, text: entry.text };
241 } else if (entry.text.startsWith(sentText)) {
242 request = { id, op: "append", documentId: entry.documentId, text: entry.text.slice(sentText.length) };
243 } else {
244 request = { id, op: "replace", documentId: entry.documentId, text: entry.text };
245 }
246 if (document) document.sentText = entry.text;
247 return request;
248 }
249
250 // noteSettled records one completed parse attempt (success or error) for
251 // the latency counters; cancellations resolve with undefined and skip it.
252 private noteSettled(entry: PendingRequest): void {
253 const duration = Math.max(0, nowMs() - entry.startedAt);
254 this.completedParses += 1;
255 this.totalParseMs += duration;
256 if (duration > this.maxParseMs) this.maxParseMs = duration;
257 }
258
259 private parseInProcessAsync(id: number, text: string): void {
260 // Async even though the work is synchronous: callers attach handlers
261 // after parse() returns, and main-thread fallback should never parse
262 // synchronously inside a React effect commit.
263 this.fallbackActive = true;
264 const injected = this.parseInProcess;
265 const run = injected
266 ? async () => injected(text)
267 : () => loadPipeline().then((pipeline) => pipeline.parseMarkdown(text));
268 void run().then(
269 (result) => {
270 const entry = this.pending.get(id);
271 if (entry) {
272 this.pending.delete(id);
273 this.noteSettled(entry);
274 if (this.disposed) entry.resolve(undefined);
275 else entry.resolve(result);
276 }
277 if (this.activeRequestId === id) this.activeRequestId = null;
278 this.fallbackActive = false;
279 void this.pump();
280 },
281 (error: unknown) => {
282 const entry = this.pending.get(id);
283 if (entry) {
284 this.pending.delete(id);
285 this.noteSettled(entry);
286 entry.reject(error instanceof Error ? error : new Error(String(error)));
287 }
288 if (this.activeRequestId === id) this.activeRequestId = null;
289 this.fallbackActive = false;
290 void this.pump();
291 },
292 );
293 }
294
295 private ensureWorker(): Promise<MarkdownWorkerLike | null> {
296 if (this.worker) return Promise.resolve(this.worker);
297 if (!this.workerPromise) {
298 const create = this.createWorker ?? createInlineMarkdownWorker;
299 this.workerPromise = create()
300 .then((worker) => {
301 if (this.disposed) {
302 worker.terminate();
303 return null;
304 }
305 worker.onmessage = (event) => this.handleMessage(event.data);
306 worker.onerror = () => this.handleWorkerFailure();
307 this.worker = worker;
308 return worker;
309 })
310 .catch(() => null);
311 }
312 return this.workerPromise;
313 }
314
315 private handleMessage(response: MarkdownParseResponse): void {
316 const entry = this.pending.get(response.id);
317 if (!entry) return; // cancelled one-shot or superseded queued work
318 this.pending.delete(response.id);
319 if (this.activeRequestId === response.id) this.activeRequestId = null;
320 if (entry.cancelled) {
321 void this.pump();
322 return;
323 }
324 this.noteSettled(entry);
325 this.fallbackActive = false;
326 if (response.error !== undefined) {
327 entry.reject(new Error(response.error));
328 } else {
329 entry.resolve(response.result ?? { blocks: [], selectionText: "", selectionRevision: 0 });
330 }
331 void this.pump();
332 }
333
334 private resetWorker(): void {
335 if (this.worker) {
336 this.worker.onmessage = null;
337 this.worker.onerror = null;
338 this.worker.terminate();
339 }
340 this.worker = null;
341 this.workerPromise = null;
342 for (const document of this.documents.values()) document.sentText = null;
343 }
344
345 /** A broken worker must not wedge parsing: reject pending work so callers
346 * fall back to their main-thread path, and reset so the next parse retries
347 * worker creation (or falls back in-process when Worker is gone). */
348 private handleWorkerFailure(): void {
349 this.resetWorker();
350 this.activeRequestId = null;
351 this.workerFailures += 1;
352 const stranded = Array.from(this.pending.values());
353 this.pending.clear();
354 for (const entry of stranded) entry.reject(new Error("markdown worker failed"));
355 }
356
357 dispose(): void {
358 if (this.disposed) return;
359 this.disposed = true;
360 this.resetWorker();
361 this.activeRequestId = null;
362 for (const entry of this.pending.values()) entry.resolve(undefined);
363 this.pending.clear();
364 this.documents.clear();
365 }
366 }
367
368 async function createInlineMarkdownWorker(): Promise<MarkdownWorkerLike> {
369 const { default: MarkdownWorkerConstructor } = await import("../components/markdown.worker?worker&inline");
370 return new MarkdownWorkerConstructor();
371 }
372
373 // ── app-level singleton with lease counting ──────────────────────────────────
374 // The worker is cheap while idle but it IS a thread: Transcript surfaces lease
375 // it while mounted and the last release terminates it, so closing every
376 // session/tab surface releases the parser thread. parse() re-spawns lazily.
377
378 let singleton: MarkdownWorkerClient | null = null;
379 let leases = 0;
380 // Process-lifetime numeric diagnostics survive worker release without retaining
381 // tasks, source text or ASTs. pending/fallback still describe the live instance.
382 const retired = { completed: 0, parseMs: 0, maxParseMs: 0, workerFailures: 0 };
383
384 export function getMarkdownWorkerClient(): MarkdownWorkerClient {
385 if (!singleton) singleton = new MarkdownWorkerClient();
386 return singleton;
387 }
388
389 export function acquireMarkdownWorkerClient(): MarkdownWorkerClient {
390 leases += 1;
391 return getMarkdownWorkerClient();
392 }
393
394 export function releaseMarkdownWorkerClient(): void {
395 if (leases === 0) return;
396 leases -= 1;
397 if (leases === 0 && singleton) {
398 const stats = singleton.stats();
399 retired.completed += stats.completed;
400 retired.parseMs += stats.avgParseMs * stats.completed;
401 retired.maxParseMs = Math.max(retired.maxParseMs, stats.maxParseMs);
402 retired.workerFailures += stats.workerFailures;
403 singleton.dispose();
404 singleton = null;
405 }
406 }
407
408 /** Explicit teardown (app shutdown, tests). Settles all pending requests. */
409 export function disposeMarkdownWorkerClient(): void {
410 leases = 0;
411 singleton?.dispose();
412 singleton = null;
413 }
414
415 /** Test hook: install a fake/spied client as the app singleton. */
416 export function setMarkdownWorkerClientForTest(client: MarkdownWorkerClient | null): void {
417 singleton?.dispose();
418 singleton = client;
419 leases = 0;
420 }
421
422 // Diagnostics provider: lets crash.ts/bench read worker counters without an
423 // eager import of this lazy-chunk module.
424 registerMarkdownWorkerDiagnostics(() => {
425 const current = singleton?.stats() ?? { pending: 0, completed: 0, avgParseMs: 0, maxParseMs: 0, fallbackActive: false, workerFailures: 0 };
426 const completed = retired.completed + current.completed;
427 return { ...current, completed,
428 avgParseMs: completed ? (retired.parseMs + current.completed * current.avgParseMs) / completed : 0,
429 maxParseMs: Math.max(retired.maxParseMs, current.maxParseMs), workerFailures: retired.workerFailures + current.workerFailures };
430 });
431
431 lines TYPESCRIPT