zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Markdown WASM runtime

Issue #4040 established the renderer-neutral runtime and scheduler against the historical @takazudo/zfb-md-wasm@0.1.0-next.96 artifact. The production runtime now pins @takazudo/zfb-md-wasm@2.15.1 and deliberately stops at the raw parser result. The official toMdastRoot adapter, application AST semantics, coordinates, transforms, rendering, and committed display revisions belong to downstream boundaries.

Decision

Markdown parsing runs in one dedicated module Worker per JavaScript realm, owned by a realm singleton. There is at most one live parser request and one live parser Worker in that realm. The Worker initializes lazily through one single-flight promise; warmup() is the optional eager path.

Production parsing imports the focused @takazudo/zfb-md-wasm/parse browser entry, which contains init, parseToAst, version, the official mdast adapter, and parse result/types without the root entry's renderer or syntax highlighter. Syntax highlighting is a separate lazy main-realm import of @takazudo/zfb-md-wasm/highlight; its JavaScript and Wasm are not requested until the first code block needs highlighting. The package root is reserved for callers that need root-only capabilities such as compile; the production app has no such caller.

Published npm tarball measurements for 2.15.1 are 693,479 bytes for the focused parse Wasm and 1,539,186 bytes for the focused highlight Wasm. The focused parse artifact remains under its 700,000-byte ceiling with 6,521 bytes of headroom. For comparison, the 2.15.1 root Wasm is 3,394,144 bytes (the previous 2.3.0 root Wasm was 3,262,328 bytes). The focused entries keep the same package version handshake and trap/recovery contract as the root entry.

The Wave 1 gate measured a 24.7 ms Chromium and 28 ms WebKit main-thread blocking interval for the 25 KiB workload. Both passed the predeclared 50 ms rejection budget, but both exceed a 16.7 ms animation frame. Keeping the synchronous Wasm call in the renderer would therefore permit visible editing jank even for a passing artifact. A dedicated Worker isolates that CPU work without the lifecycle and cross-window ambiguity of a SharedWorker.

The locked gate also measured 57.4 ms Chromium / 84 ms WebKit cold startup and 34.56 MiB median candidate RSS growth in fresh Node processes. Per-Worker JavaScript heap measurement is unavailable in both tested browser engines, so the evidence records null instead of fabricating a memory number. The historical .96 evidence emitted a 3,217,550-byte Wasm resource. That archive remains intentionally byte-identical. Current production bundles emit the focused parse resource for the Worker and, only when requested, the focused highlight resource for the main realm. Separate JavaScript realms reuse emitted files but necessarily instantiate separate Wasm memories; the parser runtime itself keeps exactly one live Worker instance.

Request and scheduling contract

request() allocates and returns { requestId, outcome } synchronously. outcome resolves exactly once as parsed, superseded, cancelled, or failed. Generation and request IDs reject stale or cross-restart messages. getSnapshot() returns a cached snapshot, and subscribe() publishes queued → active → idle/restart transitions for useSyncExternalStore. Subscriber exceptions are isolated: an observer cannot alter request settlement or prevent the scheduler from draining later work.

consumerId is a unique per-mount identity, never a draft path. Two mounted panes showing the same file must use different identities and must not supersede each other. A hidden but still-mounted preview may continue to issue work. When a mount stops owning results, it calls cancelConsumer(consumerId); disposing the singleton cancels all consumers and terminates the Worker.

The scheduler has:

  • one in-flight parse globally;

  • one latest pending request per consumer;

  • a 16 ms default debounce before dispatch;

  • at most 32 pending consumers;

  • immediate supersession of stale promises and stale-result dropping;

  • a 5 second parse timeout, followed by Worker termination and a fresh realm for future work (the timed-out input is never retried);

  • no persistent AST cache.

Raw resource guards are 2 MiB UTF-8 input, 100,000 generic children nodes, and depth 512. The main realm enforces the input guard synchronously before queue retention or postMessage cloning; the Worker repeats it as a defense in depth. The generic node/depth walk does not interpret node types, values, or coordinates.

Initialization, traps, and fallback

Initialization is bounded to two fresh-Worker attempts, with a 3 second startup timeout per attempt. A surfaced trap fails the current request closed, terminates the realm, and permits a later request to start a fresh Worker. A cancelled active mount also terminates its Worker so other consumers do not wait behind abandoned CPU work.

Termination, disposal, and replacement explicitly reject any in-progress initialization handshake, so warmup() and request initialization chains always settle. Message and error callbacks are bound to the concrete Worker object and generation that registered them; late events from a terminated realm are ignored before protocol validation and cannot poison a replacement handshake.

failure.disposition is the sole authoritative fallback policy; consumers use it or isLegacyFallbackAllowed(failure) and must not copy a reason-code list. The exact legacy-allowed set is:

  • unsupported Worker;

  • unsupported WebAssembly;

  • Worker construction failure;

  • Worker startup failure;

  • Wasm resource loading failure;

  • Wasm MIME failure when a runtime actually rejects it;

  • exhausted bounded initialization.

Diagnostics, malformed/no-AST results, input/node/depth limits, traps, timeouts, Worker crashes after startup, protocol identity violations, queue overflow, adapter failures, and unknown failures are fail-closed. A render chooses one semantic path before output and never downgrades after partial new-path output.

The runtime browser loader fetches an ArrayBuffer before compilation, so the deterministic wrong-MIME probe remains functional under both Chromium and Playwright WebKit. Production responses still use application/wasm; if a platform rejects a MIME/resource response during initialization, that enumerated failure is legacy-allowed.

Entry and platform matrix

Entry or platformRuntime/build decision
ROOT/main and writingShared vite.config.ts; one ESM Worker; production and development CSP explicitly allow same-origin Workers and Wasm compilation.
mockSame Worker/Wasm Vite contract. It is a development HTTP entry and has no Tauri production CSP.
RESTSame Worker/Wasm Vite contract. Host CSP is deployment-owned; Tauri writing development CSP covers its local origin.
webSame Worker/Wasm Vite contract. The web deployment must supply the equivalent worker-src 'self' and script-src 'wasm-unsafe-eval' headers.
adminExcluded: it does not parse or render writing-preview Markdown. Its stricter non-Wasm CSP remains unchanged.
preview-viewerMigrated (#4806): a standalone Vite app outside the tauri-app build variants, but its vite.config.ts now carries the same Worker/Wasm contract (assetsInclude: ["**/*.wasm"], plus markdownWasmWorker/zfbMdWasmOptimizeDeps imported directly from tauri-app/vite-shared.ts), and its standalone MarkdownPreview renders through @takazudo/markdown-react-renderer instead of the legacy React Markdown renderer.
macOS desktop / generated LEAFSame bundled writing entry and CSP. The installed self-recording Tauri harness is the authoritative custom-protocol/offline/WKWebView check.
iOSSame bundled renderer contract. Runtime capability/startup failure is legacy-allowed. Installed simulator/device evidence requires macOS + Xcode and remains visibly unexecuted on the current Linux host.
AndroidNo Android generated project or shipped Android target exists in this repository today. The browser runtime is capability-gated, but no installed Android result is claimed.

Evidence

tauri-app/markdown-runtime-evidence/ is isolated from production UI. Its production build emits the parser Worker plus a separately bundled fault-injection module Worker, one glue module, and one Wasm asset. The Chromium and Playwright WebKit runs block every non-loopback network request, load same-origin Worker/glue/Wasm resources under the production CSP, verify rapid coalescing and unmount recovery, and probe correct and incorrect MIME. A capability probe forces the unsupported path; the real fault Worker forces resource/init/MIME failure, trap, trap-recovery exhaustion, request timeout, Worker crash, and protocol-violation/restart outcomes without substituting main-thread fakes.

The evidence workflow also runs five separate production application builds: main, writing, mock, REST, and web. A non-UI probe entry makes the runtime resource edge observable without integrating parsing into a production component. Output inspection requires the probe-to-Worker URL, the Worker-to-glue/Wasm URLs, exactly one locked parser Worker/glue/Wasm set, and the effective main/writing production CSP. The package build separately emits dist/worker-entry.js, rewrites the distribution URL away from the source .ts path, and smoke-checks both exported dist targets.

Installed Apple WKWebView evidence cannot execute on the current Linux/WSL2 host: Xcode, an Apple SDK, and WKWebView are unavailable, and Playwright WebKit is not substituted for installed WKWebView. run-installed-macos.sh builds a minimal Tauri .app that uses the bundled application protocol and writes its own JSON result before exiting. Its validator requires tauri://localhost/, app-local runtime resources, the full real-Worker failure/recovery matrix, and a passing baseline before accepting the record. installed-evidence.json records the exact platform block with pass: false. run-installed-ios.sh initializes the same minimal harness, selects an available iPhone Simulator (or IOS_SIMULATOR_NAME), runs the production ios run path, and captures and validates its self-recorded console result. The blocked record must be replaced by installed-evidence-macos.json and installed-evidence-ios.json after those runs execute on macOS.