fix: use types.isNativeError() for cross-VM Error serialization#1164
Conversation
FatalError was not properly serialized when passed from workflow code into a step function because the Error reducer checked `value instanceof global.Error` where `global` is the VM's globalThis. Errors created in the host context (like FatalError from @workflow/errors) have a different Error prototype than the VM context, so the instanceof check returned false and the error was silently dropped. Replaced with `types.isNativeError()` from `node:util` which uses V8's internal type tag and works across VM context boundaries.
🦋 Changeset detectedLatest commit: 825bbe8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 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 |
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express | Nitro workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro | Express Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) | Nitro Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express | Nitro Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
|
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (45 failed)turso (45 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a critical bug where errors from the host context (like FatalError) were not properly serialized when passed to workflow code running in a VM context. The issue occurred because instanceof global.Error checks fail across VM boundaries since each context has its own Error constructor. The fix uses types.isNativeError() from Node.js's node:util module, which relies on V8's internal type tag and works across all VM contexts.
Changes:
- Replaced
instanceof global.Errorwithtypes.isNativeError()in the Error reducer - Added comprehensive test coverage for cross-VM Error serialization scenarios
- Updated changeset to document the patch fix
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| packages/core/src/serialization.ts | Updated Error reducer to use types.isNativeError() instead of instanceof global.Error with detailed explanatory comments |
| packages/core/src/serialization.test.ts | Added three new test cases covering host-context errors, VM-context errors, and Error subclasses in cross-VM scenarios |
| .changeset/small-houses-walk.md | Added changeset documenting the FatalError serialization fix |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Bundlers like Turbopack compile `export class FatalError extends Error
{...}` into a registration call like `e.s(["FatalError", 0, class
extends Error {...}])` — passing an anonymous class expression as a
function argument. The resulting constructor function has `name === ''`,
which broke the previous `value.constructor?.name === subclassName`
match: an instance of the bundled FatalError class no longer matched the
dedicated FatalError reducer and instead fell through to the generic
`Error` reducer, losing class identity across the workflow boundary.
This was caught by the local-prod CI matrix, where each Next.js route
gets its own bundled chunk: a real `new FatalError('fatal!')` returned
from a workflow was serialized as a plain Error and revived without
`instanceof FatalError` holding on the consumer side.
Switch the match in `reduceNamedErrorSubclassBase` to `value.name`,
which:
- works for built-in subclasses (TypeError/RangeError/… all set
`name` automatically and aren't bundled, so behavior is unchanged
in practice).
- works for FatalError/RetryableError, whose constructors set
`this.name` explicitly — robust across realms AND bundlers.
- is consistent with how `FatalError.is()` / `RetryableError.is()`
already identify their values.
Two existing cross-VM Error tests (added in #1164) used `name = 'FatalError'`
on a plain Error to stand in for any cross-realm error — which now hits
the dedicated FatalError reducer (returning a host-realm FatalError)
instead of the generic Error reducer (which constructs a VM-realm Error).
Renamed the stand-in to `'CustomError'` so they continue to exercise the
intended path.
…1513) * Add WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE to FatalError and RetryableError Add custom serialization methods to FatalError and RetryableError in @workflow/errors, enabling the SWC plugin to discover and register them through the standard class serialization pipeline. This preserves class identity (instanceof), the fatal flag, and the retryAfter date when these errors cross serialization boundaries. - Add @workflow/serde dependency to @workflow/errors - Add WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE static methods to both classes - Add unit tests verifying Instance-based round-trip serialization - Add e2e workflow tests verifying class identity preservation end-to-end * Address review feedback on FatalError/RetryableError serde - FatalError: preserve cause property when present (Copilot feedback) - RetryableError: preserve cause property when present - RetryableError: serialize retryAfter as numeric timestamp for realm safety (the Date reducer uses instanceof global.Date which fails across VM realms; timestamps sidestep that issue) - Replace e2e tests with step return value serialization (step throw path always reconstructs errors as FatalError, so those tests don't exercise the new serde code path) - Add unit tests for cause preservation on both classes * Bump fatal-retryable-error-serialization changeset to minor Adding WORKFLOW_SERIALIZE / WORKFLOW_DESERIALIZE hooks to FatalError and RetryableError is a feature, not a bug fix. * Switch FatalError/RetryableError to first-class serialization Replace the WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE static methods on FatalError and RetryableError with dedicated reducers/revivers in the common serialization module. The Instance/Class pipeline relies on the SWC plugin discovering classes and registering them by classId, which means values constructed in environments that don't run the plugin (vitest e2e runner, ad-hoc Node scripts) can't be deserialized. Treating FatalError/RetryableError as first-class serialization targets makes them round-trip from any environment with no setup, matching the behavior of TypeError, RangeError, etc. added in the previous commit. - Drop @workflow/serde dependency on @workflow/errors - Remove WORKFLOW_SERIALIZE/DESERIALIZE statics from FatalError/RetryableError - Add FatalError/RetryableError reducers to serialization/reducers/common.ts with cached base-reducer factories for the subclasses that wrap the shared shape (RetryableError, AggregateError) - Migrate unit tests off registerSerializationClass setup - Extend the errorSubclassRoundTripWorkflow e2e test to cover FatalError and RetryableError, and drop the parallel errorFatalSerdeRoundTrip / errorRetryableSerdeRoundTrip tests * Address review feedback on FatalError/RetryableError serde - Soundness: split makeErrorSubclassReducer into a shared base helper (reduceErrorBase / reduceNamedErrorSubclassBase returning the BaseErrorPayload shape) plus a thin wrapper constrained to subclass keys whose serialized shape is exactly that base payload. The AggregateError and RetryableError reducers — which extend the base with extra fields — now consume reduceNamedErrorSubclassBase directly instead of calling makeErrorSubclassReducer with an unsound type cast. The compiler now rejects accidental misuse (SimpleErrorSubclassKey type guard). - Realm safety: RetryableError reviver constructs retryAfter via new global.Date(...) to match the rest of the module and ensure the resulting Date passes instanceof global.Date checks in the target realm. - Test strength: assert serialized payloads contain the literal devalue marker ["FatalError",N] / ["RetryableError",N] rather than the bare class name (which would also match a generic Error payload whose name happens to be "FatalError"). Also assert the generic ["Error",N] marker is absent. * Match Error subclass reducers by `value.name`, not constructor name Bundlers like Turbopack compile `export class FatalError extends Error {...}` into a registration call like `e.s(["FatalError", 0, class extends Error {...}])` — passing an anonymous class expression as a function argument. The resulting constructor function has `name === ''`, which broke the previous `value.constructor?.name === subclassName` match: an instance of the bundled FatalError class no longer matched the dedicated FatalError reducer and instead fell through to the generic `Error` reducer, losing class identity across the workflow boundary. This was caught by the local-prod CI matrix, where each Next.js route gets its own bundled chunk: a real `new FatalError('fatal!')` returned from a workflow was serialized as a plain Error and revived without `instanceof FatalError` holding on the consumer side. Switch the match in `reduceNamedErrorSubclassBase` to `value.name`, which: - works for built-in subclasses (TypeError/RangeError/… all set `name` automatically and aren't bundled, so behavior is unchanged in practice). - works for FatalError/RetryableError, whose constructors set `this.name` explicitly — robust across realms AND bundlers. - is consistent with how `FatalError.is()` / `RetryableError.is()` already identify their values. Two existing cross-VM Error tests (added in #1164) used `name = 'FatalError'` on a plain Error to stand in for any cross-realm error — which now hits the dedicated FatalError reducer (returning a host-realm FatalError) instead of the generic Error reducer (which constructs a VM-realm Error). Renamed the stand-in to `'CustomError'` so they continue to exercise the intended path.

FatalError was not properly serialized when passed from workflow code into a step function because the Error reducer checked
value instanceof global.Errorwhereglobalis the VM's globalThis. Errors created in the host context (like FatalError from @workflow/errors) have a different Error prototype than the VM context, so the instanceof check returned false and the error was silently dropped.Replaced with
types.isNativeError()fromnode:utilwhich uses V8's internal type tag and works across VM context boundaries.