Add opt-in QuickJS WASM VM engine (WORKFLOW_VM=quickjs) - #3048
Conversation
🦋 Changeset detectedLatest commit: f2bb56b The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
Adds an experimental QuickJS WASM-based workflow VM engine to @workflow/core, selectable via WORKFLOW_VM=quickjs (or per-run affinity via executionContext.workflowVm), while keeping the existing node:vm replay engine as default. This expands the runtime’s portability (WASM-only platforms) and lays groundwork for later snapshotting work, while maintaining the same full event-replay semantics.
Changes:
- Introduces QuickJS runtime + entrypoint implementing full replay, pending-op extraction, and durable side-effect dispatch/queueing.
- Adds VM-compatible serialization (devalue reducers/revivers + bundled serde IIFE) and QuickJS asset embedding (WASM + extensions).
- Extends CI/test matrix and docs to cover the new
WORKFLOW_VMoption.
Reviewed changes
Copilot reviewed 26 out of 27 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/create-test-matrix.mjs | Adds an e2e leg that runs nextjs-turbopack with WORKFLOW_VM=quickjs. |
| pnpm-workspace.yaml | Excludes quickjs-wasi from minimum release age checks. |
| pnpm-lock.yaml | Locks quickjs-wasi@3.1.0. |
| packages/core/turbo.json | Adds generated QuickJS/serde artifacts to Turbo build outputs. |
| packages/core/src/telemetry/semantic-conventions.ts | Adds semantic convention keys for VM engine + QuickJS runtime stats. |
| packages/core/src/source-map.ts | Adds stripInlineSourceMap() to reduce QuickJS heap usage. |
| packages/core/src/source-map.test.ts | Adds unit tests for inline source map stripping. |
| packages/core/src/serialization/workflow-vm.ts | Adds VM-safe workflow-mode (de)serialization (no Node deps). |
| packages/core/src/serialization/workflow-vm.test.ts | Validates VM serializer round-trips + Node/VM compatibility. |
| packages/core/src/serialization/vm-bundle-entry.ts | Entry point for esbuild’d VM serde bundle (installs serialize/deserialize + ULID generator). |
| packages/core/src/serialization/reducers/common-vm.ts | VM-safe reducers/revivers (base64 via atob/btoa, error subclass support, Web API-ish objects). |
| packages/core/src/serialization/compat.test.ts | Adds compatibility coverage between new modules and legacy serialization pipeline. |
| packages/core/src/serialization/codec-devalue-vm.ts | VM-compatible devalue codec, including AbortController/Signal handling for workflow VM context. |
| packages/core/src/runtime/vm-mode.ts | Adds WORKFLOW_VM parsing + run-affinity selection logic. |
| packages/core/src/runtime/vm-mode.test.ts | Unit tests for VM mode selection precedence and validation. |
| packages/core/src/runtime/start.ts | Stamps executionContext.workflowVm at run start when WORKFLOW_VM is set. |
| packages/core/src/runtime/quickjs-runtime.ts | Implements QuickJS VM execution + replay processing + pending-op model. |
| packages/core/src/runtime/quickjs-runtime.test.ts | Adds unit tests for replay semantics, determinism, clock, abort, etc. |
| packages/core/src/runtime/quickjs-entrypoint.ts | Implements host-side entrypoint: fetch events, run VM, create events/queue work, finalize runs. |
| packages/core/src/runtime.ts | Dispatches to QuickJS engine when selected, including turbo-safe reinvocation behavior. |
| packages/core/scripts/build-vm-serde-bundle.js | New build step to generate bundled serde string module for VM bootstrap. |
| packages/core/scripts/build-quickjs-assets.js | New build step to embed QuickJS WASM + extensions into TS for runtime import. |
| packages/core/package.json | Adds build steps + quickjs-wasi dependency. |
| packages/core/.gitignore | Ignores generated QuickJS asset + serde bundle TS outputs. |
| docs/content/docs/v5/configuration/runtime-tuning.mdx | Documents WORKFLOW_VM configuration and behavior. |
| .github/workflows/tests.yml | Adds vitest-plugin matrix for WORKFLOW_VM and plumbs WORKFLOW_VM into e2e jobs. |
| .changeset/quickjs-vm-engine.md | Adds changeset for @workflow/core + workflow minor bump. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… getWorkflowQueueName for conflict requeue, Buffer-free asset decoding, function replacers for payload injection, maxEventsLimit guard
…les; harden step-listing e2e assertions against eventually-consistent reads
…l column can lag terminal status)
… assertions (analytics listing can omit attempt entirely)
…ed (encp) hook payloads open Main's sealed-box work (#3096) makes cross-deployment resumeHook() seal hook payloads to the target run's published X25519 public key. The shared start() path publishes that key regardless of engine, so QuickJS runs receive sealed payloads too — but the QuickJS entrypoint resolved only the bare symmetric key via importKey(), which cannot open encp envelopes. The first sealed hook payload wedged the run right after hook_received, timing out every hook/webhook e2e on Vercel prod (node:vm legs were fine — the node engine resolves the full capability via memoizeEncryptionKey). Resolve deriveRunPayloadKeys() in the entrypoint instead and widen the runtime's key types from CryptoKey to DecryptionKey. Writes stay symmetric (encrypt() with RunPayloadKeys takes the encr path). Regression test seals a payload exactly as resumeHook does and round-trips it through the VM.
…import, VM-leak guard, telemetry namespace, eval-string escaping - Deterministic crypto.getRandomValues/randomUUID in the VM bootstrap, drawing from the seeded Math.random (identical sequences to the node engine's vm/index.ts implementations); all crypto.subtle methods throw with step-function guidance. process.env exposed as a frozen copy, matching node. - Intl: throwing constructors (no ICU in QuickJS), and toLocale*-family methods (incl. localeCompare) throw when given an explicit locale so cross-engine divergence is loud instead of silently writing different values into the event log. No-argument forms keep working. - runtime.ts lazy-imports the QuickJS entrypoint at dispatch, keeping the ~1.3MB embedded WASM assets out of node-engine deployments. - runQuickJSWorkflow wraps the per-run phase so an exceptional exit disposes the VM instead of leaking it in a reused compute instance; corrected the misleading fail-loud comment (run_failed, not retry); warn when the event drain loop exhausts its iteration bound. - Telemetry attributes renamed quickjs.* → workflow.vm.* to stay in the file's workflow.* namespace. - Eval-string correlation-id interpolation uses JSON.stringify instead of quote-only escaping. - common-vm.test.ts pins the reducer/reviver superset invariant against common.ts so the duplicated sets can't silently drift. - Docs enumerate the remaining global-surface differences (subtle.digest, Intl, WebAssembly, Atomics); quickjs-entrypoint documents the known precondition-guard gap.
…tion + resumeId dedup) #1834 made resumeHook() fall back to enqueueing the run with a hookInput payload when the direct hook_received write fails transiently, with the runtime materializing the missing event on delivery. Only the node:vm path implemented it — the QuickJS dispatch returned before the node block, so the resilient payload was silently dropped and the new e2e timed out on every quickjs leg. - runtime.ts threads hookInput into runWorkflowWithQuickJS; the entrypoint materializes the missing hook_received after loading the event log (resumeId-keyed dedup, occurredAt from the resumeId ULID, local eventData substitution for lazy/ref responses, EntityConflict / HookNotFound handling) — mirroring the node block. - processEvents drops duplicate hook_received rows sharing a resumeId (first-in-log wins), matching the node engine's EventsConsumer dedup; the seen-set lives in the VM heap so it is deterministic per replay. Verified against the dev server with WORKFLOW_VM=quickjs: the resilient resume e2e passes and the materialization is observable in the logs; all 27 hook e2e tests green.
…ardened host codec The hardened host-side serialization (#3257) made the shared reducers/class.ts and reducers/step-function.ts depend on serialization/hardened.ts, which imports node:util and captures host intrinsics — unbundleable and meaningless inside the QuickJS guest, where the codec already runs in the guest realm. Point the VM codec at pre-hardening copies with identical wire format; the host/guest boundary hardening for this engine arrives with the host-side serde that retires the VM bundle.
…essage redelivery
Scheduling sleep wakeups by returning { timeoutSeconds } redelivers the
CURRENT queue message. When that message is a hook-resume delivery
(carrying hookInput), its redelivery re-runs the lazy-resume re-ensure
in the handler prologue; if the workflow disposed the hook during the
first delivery (dispose -> sleep), the re-ensure gets HookNotFound, the
prologue acks the message as 'nothing left to resume', and the wait
timer it carried is silently lost — the run wedges (caught by the
hookDisposeTestWorkflow e2e).
Enqueue fresh continuation messages instead, matching the node engine's
suspension handler: getWaitContinuationDispatch for pending waits
(gaining delay clamping/hop chaining and pending-wait dedup keys) and a
plain immediate message for elapsed-wait / attr_set / getConflict
requeues. A fresh message carries only runId, so its delivery always
reaches replay.
Also: read hook_received resumeId from the canonical top-level event
field (eventData.resumeId is the deprecated legacy fallback), and stop
passing hookInput into the entrypoint — the shared prologue in
runtime.ts materializes the event for both engines. Adds a VM replay
test for the hook -> dispose -> sleep shape.
pranaygp
left a comment
There was a problem hiding this comment.
Full review + local validation pass on the whole stack (review notes below, plus #3049/#3250/#3251/#3263). Inline comments carry the file-level findings; this summary covers the merge state and cross-cutting results.
Merge with main is semantically deep, not just textual. I prepared and validated a resolution locally (branch pgp/quickjs-vm-main-merge, can push on request). Three real interactions with the 7 commits main is ahead:
- Build break: #3257/#3288 make
reducers/class.ts/step-function.tsimporthardened.js, whose module scope importsnode:util— unresolvable in the VM serde bundle (esbuildplatform: 'neutral'; verified failing). Fixed with pinned VM copiesclass-vm.ts/step-function-vm.ts(the existingcommon-vm.tspattern); becomes moot once #3263 lands. - #3230 (lazy hook resumption): the entrypoint's resilient-resume backstop predates the
(runId, resumeId)claim protocol (resumeIdin eventData vs. as a create param;HookInputrenamedHookResumeInput). Aligned, and ordered the engine-agnostic consumer ensure before the QuickJS dispatch so thehookResumeInputVersionattestationstart()stamps holds for QuickJS runs. - A deterministic run-wedge found by e2e post-merge (
hookDisposeTestWorkflowhangs): this engine reuses the current hookInput-carrying message as its wake vehicle via{timeoutSeconds}redelivery. world-local validates hook disposal before the resumeId-claim convergence, so the redelivery for a since-disposed hook throwsHookNotFoundError, which the consumer ensure treats as "nothing left to resume" and consumes — destroying the run's only wake. Fixed in world-local (convergence-before-disposal, matching workflow-server's semantics; world-postgres is exempt by capability) + regression test. Note the node engine has a narrower latent exposure to the same class onmaintoday via thereinvoke(0)hook-conflict redelivery.
Validation of the merged result: core 1866 passed/3 xfail, world-local 508 passed, e2e 135/135 under WORKFLOW_VM=quickjs (nextjs-turbopack dev, world-local), node-engine spot checks green.
workerd is genuinely unblocked by this PR — I ran a real 3-invocation replay (step → sleep → completion) through runQuickJSWorkflow inside wrangler dev, with identical correlationIds across fresh VMs and the WASI replay clock verified. One change needed: an asset hook so hosts can install pre-compiled WebAssembly.Modules (workerd bans runtime WebAssembly.compile); prototype + workerd CI job on branch pgp/quickjs-workerd-smoke. Two premise updates: compression is no longer a workerd blocker (nodejs_compat now ships zstd in node:zlib), and node:vm on workerd now feature-detects as present (typeof passes, calls throw) — engine auto-detection must trial-call.
Follow-ups worth tracking as issues (acceptable for an opt-in engine, not blocking):
- No replay-divergence detection (node has
EventsConsumer→ReplayDivergenceError/CORRUPTED_EVENT_LOGrecovery; this engine drops unconsumed events on the floor) — given this quarter's corrupted-log work, please file this one. - No terminal-event guard before
run_completed/run_failedwrites;RunExpiredErrornot tolerated indispatchPendingOps. - VM clock advances per scanned event rather than per consumed event.
- Engine-affinity holes: runs started with
WORKFLOW_VMunset are unstamped, and child runs started from steps (spawnChildWorkflowin 99_e2e) execute on node even in the quickjs CI legs — the vercel-prod quickjs legs partly test node. - Observability:
quickjs-runtime.tshas zero telemetry (85–95% of each VM-bound invocation untraced, noworkflow.runspan, terminalworkflow.run.statusnever set); measured empirically against node with a span capture. - Event pagination bypasses
loadWorkflowRunEvents(loses its dedup/progress guards + span, and refetches the full log each invocation). .changeset/quickjs-inline-steps.mdbelongs to #3049, not this PR.- Package size: embedded assets add ~518 KB gzipped to
@workflow/core; the quickjs matrix roughly doubles the e2e legs — worth explicit sign-off.
CI on this PR is green; the only blocker is the merge state, and the prepared branch above addresses it plus the inline findings below.
| // on re-entry), and manages its own run_completed / | ||
| // run_failed lifecycle. When the QuickJS engine is in | ||
| // effect, return immediately after dispatch. | ||
| if (useQuickJSVm(workflowRun)) { |
There was a problem hiding this comment.
Blocker: this dispatch sits outside the run-level try/catch, so no QuickJS failure can ever produce run_failed.
Traced the enclosing blocks: the while (true), its try, and the catch that ends in the terminal run_failed write all begin after this block returns. Consequences: the entrypoint's MaxEventsExceededError (whose comment claims it propagates to a catch that records run_failed) instead nacks the message; same for a WASM OOM at the 256 MB limit, a bundle-eval failure, or an escaping JSException. Every deterministic failure redelivers up to MAX_QUEUE_DELIVERIES (48) and dies as MAX_DELIVERIES_EXCEEDED — 48 wasted invocations and a misleading terminal reason.
Fix is small: move the dispatch inside the loop's try (validated on pgp/quickjs-vm-main-merge — e2e stays green), and update the two comments in quickjs-entrypoint.ts/quickjs-runtime.ts that assert the current behavior.
There was a problem hiding this comment.
Fixed in 4eca96a — the dispatch now runs inside the replay loop's try, so escaping engine failures reach the catch that classifies and records run_failed (transient world errors still rethrow for redelivery). Both comments updated to describe the actual propagation. Verified: core suite green, hook/sleep/fail e2e green under WORKFLOW_VM=quickjs.
| if (result.event?.eventType === 'hook_conflict') { | ||
| await queueMessage( | ||
| world, | ||
| getWorkflowQueueName(workflowRun.workflowName), |
There was a problem hiding this comment.
Queue namespace is dropped here (and at the step dispatch at L359) — getWorkflowQueueName(workflowRun.workflowName) with no namespace argument, where the node path passes it at nine sites in runtime.ts.
Currently latent: resolveQueueNamespace falls back to process.env.WORKFLOW_QUEUE_NAMESPACE and no builder passes a namespace explicitly today — but the builders bake the namespace into generated routes at build time and document that the env var is not needed at runtime, so the moment a namespaced deployment exists, these publishes go to __wkf_workflow_* while consumers listen on __<ns>_wkf_workflow_* and steps are silently never picked up. Same class as the hook_conflict namespace bug fixed earlier in the port (ff400af).
Fix: thread namespace from the runtime dispatch through runWorkflowWithQuickJS into both sites (done on pgp/quickjs-vm-main-merge).
There was a problem hiding this comment.
Fixed in 4eca96a — namespace is threaded from the runtime dispatch through runWorkflowWithQuickJS into all four publish sites (step dispatch, hook_conflict requeue, and the two continuation enqueues that replaced the {timeoutSeconds} redelivery).
| // terminal-drain mode (the workflow already finished; the | ||
| // event is the durable record, matching the node:vm drain). | ||
| if (params.queueSteps) { | ||
| const traceCarrier = await serializeTraceCarrier(); |
There was a problem hiding this comment.
Trace-carrier drift: this captures the current context where the node engine forwards the run-origin carrier (getNextTraceCarrier, runtime.ts). In the default linked trace mode the contract is that every invocation links back to workflow.start in a star (pinned by runtime-trace-mode.test.ts); with serializeTraceCarrier() here, invocations chain to each other instead — observed empirically with a span capture (invocations 2/3/4 linking to invocation 1). Looks cosmetic on world-local's synchronous queue; on VQS, where invocations land in separate traces, it fragments the run view.
Related: the hook_conflict requeue above (L183–190) carries no traceCarrier (and no requestedAt) at all.
Fix: thread the nextTraceCarrier() closure from runtime.ts through runWorkflowWithQuickJS (done on pgp/quickjs-vm-main-merge).
There was a problem hiding this comment.
Fixed in 4eca96a — the nextTraceCarrier closure is threaded from runtime.ts into the entrypoint and used for every publish, so linked-mode invocations star around workflow.start instead of chaining. The hook_conflict requeue now carries traceCarrier + requestedAt too. (serializeTraceCarrier() remains only as a standalone-caller fallback when no accessor is provided.)
| }; | ||
| } | ||
|
|
||
| function createInterruptHandler(): () => boolean { |
There was a problem hiding this comment.
Interrupt budget is hardcoded to 30 s, ignoring the configurable replay budget. The node engine uses ReplayBudget (REPLAY_TIMEOUT_MS, default 240 000, env-configurable). A workflow whose replay node handles fine gets interrupted here at 30 s — and per the try/catch placement issue, that interrupt currently becomes a redelivery loop rather than a failure. One-line fix: getReplayTimeoutMs() (done on pgp/quickjs-vm-main-merge).
There was a problem hiding this comment.
Fixed in 4eca96a — interrupt handler now uses getReplayTimeoutMs() (default 240s, env-configurable), and with the dispatch inside the loop's try an interrupt is recorded as run_failed rather than looping redeliveries.
| // executing on the engine it started on (the same deployment can | ||
| // serve both VM engines). Unknown values throw — see | ||
| // getWorkflowVmFromEnv(). | ||
| const workflowVm = getWorkflowVmFromEnv(); |
There was a problem hiding this comment.
Affinity hole: the engine is only stamped when WORKFLOW_VM is set in this process's env. Runs started with it unset are unstamped and follow whatever the env says at replay time — an env flip mid-run switches engines. Concretely today: spawnChildWorkflow/spawnChildWorkflowRun in 99_e2e call start() from inside steps, and in the vercel-prod matrix WORKFLOW_VM is only set on the test runner — so those child runs execute on node:vm even in the quickjs legs, making that coverage weaker than the matrix implies. Matches the unconditional-stamping requirement already noted on #3253; worth resolving before any default flip.
There was a problem hiding this comment.
Agreed — tracking as a follow-up alongside the unconditional-stamping note on #3253 rather than changing stamping semantics in this PR: always writing executionContext.workflowVm (including 'node') pins the engine per run and deserves its own change + rollout thought before any default flip. The step-spawned-child coverage gap in the vercel-prod quickjs legs is a good catch; will fold it into that issue.
…+ run-origin trace carrier threading, configurable interrupt budget - Move the QuickJS engine dispatch inside the replay loop's try so escaping engine failures (MaxEventsExceededError, WASM OOM, bundle-eval errors) reach the catch that classifies and records run_failed, instead of nacking the message and burning all 48 queue redeliveries into MAX_DELIVERIES_EXCEEDED. Transient world errors still rethrow for redelivery. Updated the two comments that describe the propagation. - Thread the queue namespace from runtime.ts through runWorkflowWithQuickJS into every message publish (step dispatch, hook_conflict requeue, immediate requeue, wait continuation) — without it, publishes on a namespaced deployment land on __wkf_workflow_* while consumers listen on __<ns>_wkf_workflow_*. - Thread the run-origin nextTraceCarrier accessor through instead of capturing the current invocation context, so linked-mode invocations form a star around workflow.start rather than chaining; the hook_conflict requeue now carries a traceCarrier and requestedAt. - Replace the hardcoded 30s VM interrupt budget with the configurable replay budget (getReplayTimeoutMs, default 240s), matching the node engine.
|
The branches referenced in my review are now pushed: |
| '@workflow/core': patch | ||
| 'workflow': patch | ||
| --- | ||
|
|
||
| QuickJS engine performance: cache compiled WebAssembly modules process-wide, and execute steps inline in a live-VM continuation loop (no queue round-trip per step, cheap events fed before step bodies, delayed wait-continuation dispatch for racing timers). |
There was a problem hiding this comment.
A performance improvement to the change that is in the same PR shouldn't get a separate changeset
| '@workflow/core': patch | |
| 'workflow': patch | |
| --- | |
| QuickJS engine performance: cache compiled WebAssembly modules process-wide, and execute steps inline in a live-VM continuation loop (no queue round-trip per step, cheap events fed before step bodies, delayed wait-continuation dispatch for racing timers). | |
| --- |
|
No backport to This commit adds an entirely new opt-in workflow execution engine ( To override, re-run the Backport to stable workflow manually via |
Summary
Adds an experimental QuickJS WASM VM engine for workflow execution, opt-in via
WORKFLOW_VM=quickjs(or per-runexecutionContext.workflowVm). The engine performs the same full event replay as the defaultnode:vmengine, but runs workflow code in a QuickJS-NG VM compiled to WebAssembly viaquickjs-wasi@3.1.0.This is PR 1 of the incremental revival of the snapshot-runtime effort (#1300, RFC #1298): the QuickJS VM is deliberately decoupled from snapshotting. Motivations:
node:vm(e.g. Cloudflare Workers — WASM only)executionContextatstart(), so a run keeps executing on the engine it started onResults
WORKFLOW_VM=quickjsagainst the nextjs-turbopack workbench (local dev, world-local)How it works
WORKFLOW_VM=node|quickjs(runtime/vm-mode.ts), defaultnode. Per-run affinity viaexecutionContext.workflowVm, validated at startup.runtime/quickjs-runtime.ts: every invocation creates a fresh QuickJS VM, evaluates the workflow bundle, re-executes the workflow from the top, and resolves awaited primitives from the recorded event log (processEvents). Workflow primitives (useStep,sleep,createHook,setAttributes,AbortController) are implemented as VM-side bootstrap JS backed by a pending-op/resolver model.runtime/quickjs-entrypoint.ts: loads the full event log, runs the VM, and dispatches durable side effects for pending ops (step/hook/wait/attribute events + step queueing via the unified V2 queue) on suspension, plus a terminal drain (abort recordings, system-hook disposal, fire-and-forget writes) on completion/failure — mirroringdrainPendingQueueItems.vm-serde-bundle), with the WASM binary + native extensions (encoding, headers, url, structured-clone) base64-embedded so no filesystem access or file tracing is needed at runtime.Determinism model
Full replay requires every invocation of a run to regenerate identical correlationIds and observe identical time:
runId:workflowName:deploymentId(invocation-stable — notably NOTstartedAt, which differs between turbo's synthesized run object and the durably stored run), matching the node:vm engine. Identical ids across concurrent invocations are load-bearing for the world's per-(run, correlation) dedup.Date.now()/new Date()inside the VM read a host-controlled WASIclock_time_getoverride that starts at run creation and advances to each processed event'screatedAt— the same deterministic replay clock as the node:vm engine'supdateTimestamp.Feature parity work (beyond the original branch)
The WIP branch predated ~2.5 months of main. Ported into the VM model:
setAttributes(attr_set events + immediate re-invoke), hook-backedAbortController/AbortSignal(system hooks, abort stream packets, statics),hook.getConflict()+ conflict adoption (conflictingRunrevived through the VM class registry),HookConflictError/RuntimeDecryptionErrorserialization, byte-stream framing round-trip (webhook bodies), cross-run writable forwarding symbols, bound step proxies (.bindoverride preservingstepId/__boundThis), decrypt+decompress of specVersion≥5 payloads before VM handoff, and turbo-safe re-invocation viareinvoke().Scope / deliberately deferred
world.snapshotsinterface, no snapshot persistence, no compression/encryption of VM state — that is PR 3/4 (threshold-based snapshotting) per the roadmapWORKFLOW_VMset project-side); CI covers aquickjsaxis on the vitest-plugin job and a nextjs-turbopack leg across the local dev/prod/postgres e2e jobsquickjs-wasiadded tominimumReleaseAgeExclude(3.1.0 published same-day; vercel-labs package)Docs
Documented
WORKFLOW_VMin v5 Runtime Tuning (docs/content/docs/v5/configuration/runtime-tuning.mdx).