From e9e2529317c6b8ffaf8e4189eef22b609f08facd Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:58:40 -0700 Subject: [PATCH 1/2] gate retention on the hardened serializer's guest-code report instead of primitives-only args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the isPrimitiveStepArgument allowlist with the GuestCodeStats sink that dehydrateStepArguments already exposes: a boundary retains unless serializing its step inputs actually executed workflow code (getters, proxy traps, custom serializers), plus a descriptor-walk probe for a replaced Error.prepareStackTrace — the one execution path the sink cannot see, because the serializer treats V8's engine stack getter as engine-provided. Plain data and standard built-ins (Map, Set, Date, RegExp, Error, typed arrays, URL, Headers) now stay on the fast path, including under prototype patching and polyfills, since serialization reads them through captured intrinsics. --- .changeset/retained-vm-guest-code-gate.md | 6 + .../docs/v5/configuration/runtime-tuning.mdx | 2 +- packages/core/src/retained-vm-loop.test.ts | 106 ++++++++++++++++++ .../src/runtime/suspension-handler.test.ts | 82 ++++++++++++++ .../core/src/runtime/suspension-handler.ts | 68 ++++++----- packages/core/src/serialization.ts | 71 ++++++------ .../core/src/serialization/hardened.test.ts | 24 +++- .../core/src/serialization/reducers/common.ts | 33 +++++- 8 files changed, 312 insertions(+), 80 deletions(-) create mode 100644 .changeset/retained-vm-guest-code-gate.md diff --git a/.changeset/retained-vm-guest-code-gate.md b/.changeset/retained-vm-guest-code-gate.md new file mode 100644 index 0000000000..6ab0d16f66 --- /dev/null +++ b/.changeset/retained-vm-guest-code-gate.md @@ -0,0 +1,6 @@ +--- +'@workflow/core': minor +'workflow': minor +--- + +Retained-VM boundaries now accept plain data and standard built-ins (`Map`, `Set`, `Date`, typed arrays, `URL`, `Headers`, …) as step inputs. The suspension handler gates retention on the hardened serializer's guest-code report: boundaries whose serialization executed workflow code or observable engine state (getters, proxies, custom serializers, `Error` stack materialization) fall back to ordinary replay. diff --git a/docs/content/docs/v5/configuration/runtime-tuning.mdx b/docs/content/docs/v5/configuration/runtime-tuning.mdx index 098fde3df2..24d50f9e8a 100644 --- a/docs/content/docs/v5/configuration/runtime-tuning.mdx +++ b/docs/content/docs/v5/configuration/runtime-tuning.mdx @@ -101,7 +101,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Default: enabled - Keeps the suspended workflow VM alive across inline steps within one invocation, so each iteration of the inline loop appends only the newly written events instead of replaying the whole event log in a fresh VM. - Suspensions involving hooks, waits, or attributes — and any replay divergence — always fall back to a full replay. -- Step inputs of primitive values remain retainable; anything whose serialization could execute workflow code falls back to replay for that boundary. (Support for plain objects, arrays, and standard built-ins lands in a follow-up.) +- Step inputs made of plain data (objects, arrays, primitives) and standard built-ins (`Map`, `Set`, `Date`, `RegExp`, typed arrays, `ArrayBuffer`, `URL`, `Headers`) remain retainable: serialization reads them through captured intrinsics, so patching or polyfilling built-in prototypes does not affect retention. Anything whose serialization executes workflow code or perturbs engine state the workflow can observe — getters, proxies, custom class serializers, and `Error` instances (serializing one materializes its lazy `stack`) — falls back to replay for that boundary. - Set `0` or `false` to replay from scratch in a fresh VM on every iteration. ### `WORKFLOW_INLINE_OWNERSHIP` diff --git a/packages/core/src/retained-vm-loop.test.ts b/packages/core/src/retained-vm-loop.test.ts index 9e8beeef6f..39ff8f64e6 100644 --- a/packages/core/src/retained-vm-loop.test.ts +++ b/packages/core/src/retained-vm-loop.test.ts @@ -113,6 +113,69 @@ const mixedBatchWorkflow = `const s1 = globalThis[Symbol.for("WORKFLOW_USE_STEP" } globalThis.__private_workflows = new Map([["workflow", workflow]]);`; +/** + * A two-step workflow source: optional prelude, then `s1(argA)` and + * `s2(argB)` in sequence. The interesting part of each fixture is exactly + * (prelude, argA, argB). + */ +function twoStepSource(prelude: string, argA: string, argB = ''): string { + return `const s1 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s1"); + const s2 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s2"); + ${prelude} + async function workflow() { + const a = await s1(${argA}); + const b = await s2(${argB}); + return a + b; + } + globalThis.__private_workflows = new Map([["workflow", workflow]]);`; +} + +// Map/Date/typed-array arguments serialize through captured host intrinsics +// (see serialization/hardened.ts), so these boundaries stay retainable. +const builtinArgsWorkflow = twoStepSource( + '', + '{ index: new Map([["k", 1]]), when: new Date(1234) }', + 'new Uint8Array([1, 2, 3])' +); + +// The Temporal / core-js pattern: polyfills add new data-valued methods to +// built-in prototypes and constructor statics. Serialization never reads +// them, so retention is unaffected. +const polyfillArgsWorkflow = twoStepSource( + `Date.prototype.toTemporalInstant = function () { return "instant"; }; + Set.prototype.union = function (other) { return new Set([...this, ...other]); }; + Object.groupBy = function () { return {}; };`, + 'new Date(1234)', + 'new Set([1, 2])' +); + +// Replacing a serialization-relevant member (Date.prototype.toISOString) +// does not affect retention: the Date reducer reads through captured host +// intrinsics (see serialization/hardened.ts), so the patched member never +// executes and the serialized bytes stay pristine in both modes. +const patchedDateArgWorkflow = twoStepSource( + 'Date.prototype.toISOString = function () { return "patched"; };', + 'new Date(1234)' +); + +// Serializing an Error with a lazy stack records the read (it runs the +// engine's format-and-cache, and any Error.prepareStackTrace) — the +// boundary demotes and the formatter's side effects land in a doomed VM. +const prepareStackTraceWorkflow = twoStepSource( + 'Error.prepareStackTrace = () => "formatted";', + 'new Error("boom")' +); + +// A formatter that deletes itself during the stack read still demotes: the +// gate records the stack read itself, not the formatter's presence. +const selfDeletingFormatterWorkflow = twoStepSource( + `Error.prepareStackTrace = () => { + delete Error.prepareStackTrace; + return "formatted"; + };`, + 'new Error("boom")' +); + // `crypto.subtle.digest` computes synchronously via node:crypto, so a // digest-using VM stays quiescent at suspension and remains retainable. const digestWorkflow = `const s1 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s1"); @@ -318,6 +381,49 @@ describe('retained VM through the inline replay loop', () => { expect(vmBuilds).toBeGreaterThan(1); }); + it('retains boundaries whose args are supported built-ins', async () => { + const { vmBuilds, output } = await drive( + 'wrun_retained_builtins', + builtinArgsWorkflow + ); + expect(output).toBeInstanceOf(Uint8Array); + expect(vmBuilds).toBe(1); + }); + + it('retains boundaries when prototypes carry polyfilled data methods', async () => { + const { vmBuilds, output } = await drive( + 'wrun_retained_polyfill', + polyfillArgsWorkflow + ); + expect(output).toBeInstanceOf(Uint8Array); + expect(vmBuilds).toBe(1); + }); + + it('retains a Date arg even when a serialization member is replaced', async () => { + const { vmBuilds, output } = await drive( + 'wrun_retained_patched_date', + patchedDateArgWorkflow + ); + expect(output).toBeInstanceOf(Uint8Array); + expect(vmBuilds).toBe(1); + }); + + it('demotes when the workflow replaced Error.prepareStackTrace', async () => { + const { vmBuilds } = await drive( + 'wrun_retained_prepare_stack_trace', + prepareStackTraceWorkflow + ); + expect(vmBuilds).toBeGreaterThan(1); + }); + + it('demotes when the formatter deletes itself during serialization', async () => { + const { vmBuilds } = await drive( + 'wrun_retained_self_deleting_formatter', + selfDeletingFormatterWorkflow + ); + expect(vmBuilds).toBeGreaterThan(1); + }); + it('retains a VM that used the synchronous crypto.subtle.digest', async () => { const { vmBuilds, result } = await drive( 'wrun_retained_digest', diff --git a/packages/core/src/runtime/suspension-handler.test.ts b/packages/core/src/runtime/suspension-handler.test.ts index 927e8a1352..78e02650c1 100644 --- a/packages/core/src/runtime/suspension-handler.test.ts +++ b/packages/core/src/runtime/suspension-handler.test.ts @@ -1,3 +1,4 @@ +import { runInNewContext } from 'node:vm'; import { PreconditionFailedError } from '@workflow/errors'; import type { WorkflowRun, World } from '@workflow/world'; import { describe, expect, it, vi } from 'vitest'; @@ -508,3 +509,84 @@ describe('handleSuspension', () => { ).rejects.toBeInstanceOf(PreconditionFailedError); }); }); + +describe('retainedStepInputsSafe (serialization passivity gate)', () => { + function stepPending(args: unknown[]) { + return new Map([ + [ + 'step_1', + { + type: 'step' as const, + correlationId: 'step_1', + stepName: 'someStep', + args, + }, + ], + ]); + } + + /** An object with a VM-realm getter — exactly what the sink records. */ + function vmGetterObject() { + return runInNewContext( + `const o = {}; + Object.defineProperty(o, 'lazy', { + enumerable: true, + get: () => 'computed', + }); + o` + ); + } + + async function runSuspension(args: unknown[]) { + const eventsCreate = vi + .fn() + .mockImplementation(async (_runId, event) => ({ event })); + const world = createWorld(eventsCreate); + return handleSuspension({ + suspension: new WorkflowSuspension(stepPending(args), globalThis), + world, + run, + }); + } + + it('reports safe for plain data and supported built-ins', async () => { + const result = await runSuspension([ + { nested: [{ ok: true }, 'text', 42n], flag: false }, + new Map([['k', new Set([1])]]), + new Date(1700000000000), + new Uint8Array([1, 2, 3]), + /pattern/gi, + new URL('https://example.com/'), + ]); + expect(result.retainedStepInputsSafe).toBe(true); + }); + + it('reports unsafe for an Error argument (stack materialization)', async () => { + // Serializing an error reads `stack`, an own engine accessor whose first + // invocation formats-and-caches the trace and runs any + // `Error.prepareStackTrace` — neither is repeated by a cold replay, so + // the boundary must demote. + const result = await runSuspension([new Error('lazy stack')]); + expect(result.retainedStepInputsSafe).toBe(false); + }); + + it('reports unsafe when serializing an argument executes a getter', async () => { + const value = vmGetterObject(); + const result = await runSuspension([{ deep: [value] }]); + expect(result.retainedStepInputsSafe).toBe(false); + }); + + it('reports unsafe when an argument is a proxy', async () => { + const result = await runSuspension([new Proxy({ a: 1 }, {})]); + expect(result.retainedStepInputsSafe).toBe(false); + }); + + it('still serializes recorded inputs successfully (bytes are unaffected)', async () => { + const value = vmGetterObject(); + const result = await runSuspension([value]); + expect(result.retainedStepInputsSafe).toBe(false); + // The step is still prepared for execution as usual (a single uncreated + // step always lands in the lazy inline slice). + expect(result.lazyInlineSteps).toHaveLength(1); + }); +}); diff --git a/packages/core/src/runtime/suspension-handler.ts b/packages/core/src/runtime/suspension-handler.ts index a5fac2353a..4e189c77d5 100644 --- a/packages/core/src/runtime/suspension-handler.ts +++ b/packages/core/src/runtime/suspension-handler.ts @@ -25,6 +25,7 @@ import type { WorkflowSuspension, } from '../global.js'; import { runtimeLogger } from '../logger.js'; +import type { GuestCodeStats } from '../serialization/hardened.js'; import { dehydrateStepArguments } from '../serialization.js'; import * as Attribute from '../telemetry/semantic-conventions.js'; import { getAbortStreamIdFromToken } from '../util.js'; @@ -36,21 +37,6 @@ import { } from './helpers.js'; import { ReplayRecoveryReporter } from './replay-recovery-reporter.js'; -// Serializing a primitive executes no code of any kind. BigInt is excluded: -// its encoding calls a prototype method. Widened to plain data and standard -// built-ins by the retained-input walker in a follow-up. (Distinct from -// replay-payload-cache's `isMemoizablePrimitive`, a size-gated memoization -// filter — do not merge them.) -function isPrimitiveStepArgument(value: unknown): boolean { - return ( - value === null || - value === undefined || - typeof value === 'boolean' || - typeof value === 'number' || - typeof value === 'string' - ); -} - export interface SuspensionHandlerParams { suspension: WorkflowSuspension; world: World; @@ -144,7 +130,12 @@ export interface SuspensionHandlerResult { * durably creating the user's hooks doesn't count as runtime overhead. */ hookCreationMs: number; - /** Whether every newly serialized step input was passive retained-VM data. */ + /** + * Whether serializing this suspension's new step inputs was passive (did + * not execute workflow-owned code such as getters, proxy traps, or custom + * serializers). `false` means the retained VM may have diverged from what + * a cold replay would compute, so the caller must demote to replay. + */ retainedStepInputsSafe: boolean; } @@ -547,20 +538,15 @@ export async function handleSuspension({ // Serialization always runs through the one ordinary path below, so the // durable bytes cannot depend on retention. What retention needs to know is - // whether that serialization will execute workflow code (getters, hooks, - // patched prototype members) — side effects a cold replay would not repeat. - // For now only primitive arguments are provably passive (serializing them - // executes no code at all); a follow-up widens this to plain data and the - // standard built-ins. If any input in the batch is not provably passive, - // the caller demotes the session so the side effects land in a VM that is - // about to be discarded, exactly like the pre-retention runtime. - const retainedStepInputsSafe = stepItems.every( - (item) => - !stepsNeedingCreation.has(item.correlationId) || - (item.thisVal === undefined && - item.closureVars === undefined && - item.args.every(isPrimitiveStepArgument)) - ); + // whether that serialization *executed* workflow code (getters, proxy + // traps, custom serializers) — side effects a cold replay would not + // repeat, since a replay skips dehydration for already-recorded steps. + // The hardened serializer records exactly that into this sink (see + // ../serialization/hardened.ts); when any input in the batch records an + // execution, the caller demotes the session so the side effects land in a + // VM that is about to be discarded, exactly like the pre-retention + // runtime. + const guestCodeStats: GuestCodeStats = { executions: [] }; // Lazy inline start: defer the step_created write for up to // `getMaxInlineSteps()` steps the caller will run inline (in parallel). Each @@ -595,6 +581,10 @@ export async function handleSuspension({ if (stepsNeedingCreation.has(queueItem.correlationId)) { ops.push( (async () => { + // Per-step sink, merged below: the dehydrate wrapper emits span + // attributes from the sink it is handed, so sharing one across + // steps would re-emit (and misattribute) earlier steps' entries. + const stepGuestCode: GuestCodeStats = { executions: [] }; const dehydratedInput = await dehydrateStepArguments( { args: queueItem.args, @@ -605,8 +595,10 @@ export async function handleSuspension({ encryptionKey, suspension.globalThis, false, - compression + compression, + stepGuestCode ); + guestCodeStats.executions.push(...stepGuestCode.executions); // Deferred (lazy) inline step: skip the step_created write — the // caller's inline executeStep will send a lazy step_started carrying // this input, and the world creates the step (entity + synthetic @@ -752,6 +744,20 @@ export async function handleSuspension({ // step_created and re-dispatches, and recovers the run instead of orphaning it. await settlePhase(ops); + // The step-input dehydrations above have settled, so the sink is final. + const retainedStepInputsSafe = guestCodeStats.executions.length === 0; + if (!retainedStepInputsSafe) { + runtimeLogger.debug( + 'Serializing step inputs executed workflow code; falling back to replay instead of retaining the VM', + { + workflowRunId: runId, + executions: guestCodeStats.executions + .slice(0, 5) + .map((e) => (e.detail ? `${e.kind}(${e.detail})` : e.kind)), + } + ); + } + // Rebuild the inline batch in deterministic order. `lazyInlineCorrelationIds` // is a Set seeded from the ordered first-N slice, so iterating it preserves // stepItems order; every id in it was set by the lazy branch above. diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index 0257bf703a..c77ec954a2 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -3,6 +3,7 @@ import { SerializationError, WorkflowRuntimeError, } from '@workflow/errors'; +import { once } from '@workflow/utils'; import { envNumber } from '@workflow/world'; import { parse, stringify, unflatten } from 'devalue'; import { monotonicFactory } from 'ulid'; @@ -1592,6 +1593,10 @@ import type { function getAllBaseReducers( global: Record = globalThis ): Partial { + const requestPrototype = once(() => getHostClassPrototype(global, 'Request')); + const responsePrototype = once(() => + getHostClassPrototype(global, 'Response') + ); // Class/Instance MUST come before Error so that custom Error subclasses // with WORKFLOW_SERIALIZE take precedence (devalue uses first-match-wins). return { @@ -1604,10 +1609,7 @@ function getAllBaseReducers( // Chain walk rather than `instanceof global.Request`: see the // ReadableStream reducer in getWorkflowReducers for why. Reads go // through descriptors so a getter cannot run unreported. - if ( - !isInstanceOfPrototype(value, getHostClassPrototype(global, 'Request')) - ) - return false; + if (!isInstanceOfPrototype(value, requestPrototype.value)) return false; const data: SerializableSpecial['Request'] = { method: readProperty(value, 'method') as string, url: readProperty(value, 'url') as string, @@ -1642,10 +1644,7 @@ function getAllBaseReducers( }, Response: (value) => { // See the Request reducer above. - if ( - !isInstanceOfPrototype(value, getHostClassPrototype(global, 'Response')) - ) - return false; + if (!isInstanceOfPrototype(value, responsePrototype.value)) return false; return { type: readProperty(value, 'type') as Response['type'], url: readProperty(value, 'url') as string, @@ -1992,13 +1991,6 @@ export function getExternalReducers( * class. `undefined` when the runtime lacks the class, in which case * identification falls back to the infrastructure symbols alone. */ -function getStreamPrototype( - global: Record, - kind: 'Readable' | 'Writable' -): object | undefined { - return getHostClassPrototype(global, `${kind}Stream`); -} - /** * The prototype of a host class that may also be injected into the sandbox. * Prefers the sandbox binding (the same host class object in practice) and @@ -2009,28 +2001,32 @@ function getHostClassPrototype( global: Record, name: string ): object | undefined { - const ctor = - global[name] ?? (globalThis as Record)[name] ?? undefined; - return typeof ctor === 'function' ? ctor.prototype : undefined; -} - -function getAbortControllerPrototype( - global: Record -): object | undefined { - const ctor = global.AbortController ?? globalThis.AbortController; - return typeof ctor === 'function' ? ctor.prototype : undefined; -} - -function getAbortSignalPrototype( - global: Record -): object | undefined { - const ctor = global.AbortSignal ?? globalThis.AbortSignal; - return typeof ctor === 'function' ? ctor.prototype : undefined; + // Descriptor reads (`readProperty`), not bare gets: an accessor or Proxy + // planted on the sandbox global (or the constructor) must be recorded in + // the guest-code sink — it gates VM retention — not silently executed. + // Callers wrap this in `once(...)` per reducer set, so the read happens + // exactly once per serialize pass, on the first guard invocation — inside + // the pass's sink scope, never per value. + const ctor = readProperty(global, name) ?? readProperty(globalThis, name); + if (typeof ctor !== 'function') return undefined; + return readProperty(ctor, 'prototype') as object | undefined; } export function getWorkflowReducers( global: Record = globalThis ): Partial { + const readableStreamPrototype = once(() => + getHostClassPrototype(global, 'ReadableStream') + ); + const writableStreamPrototype = once(() => + getHostClassPrototype(global, 'WritableStream') + ); + const abortControllerPrototype = once(() => + getHostClassPrototype(global, 'AbortController') + ); + const abortSignalPrototype = once(() => + getHostClassPrototype(global, 'AbortSignal') + ); return { ...getAllBaseReducers(global), @@ -2045,7 +2041,7 @@ export function getWorkflowReducers( // the sandbox can define and which ran for every value the earlier // reducers did not claim. Reads below go through descriptors so a // getter on a step argument cannot run unreported. - if (!isInstanceOfPrototype(value, getStreamPrototype(global, 'Readable'))) + if (!isInstanceOfPrototype(value, readableStreamPrototype.value)) return false; // Check if this is a fake stream storing BodyInit from Request/Response constructor @@ -2076,7 +2072,7 @@ export function getWorkflowReducers( }, WritableStream: (value) => { // See the ReadableStream reducer above for why this walks the chain. - if (!isInstanceOfPrototype(value, getStreamPrototype(global, 'Writable'))) + if (!isInstanceOfPrototype(value, writableStreamPrototype.value)) return false; const name = readProperty(value, STREAM_NAME_SYMBOL) as string; if (!name) { @@ -2118,7 +2114,7 @@ export function getWorkflowReducers( const ownSymbol = readProperty(value, ABORT_STREAM_NAME); const isNativeAbortController = isInstanceOfPrototype( value, - getAbortControllerPrototype(global) + abortControllerPrototype.value ); if (ownSymbol === undefined && !isNativeAbortController) { // Not ours and not a native controller — but a foreign controller @@ -2142,7 +2138,7 @@ export function getWorkflowReducers( readProperty(value, ABORT_STREAM_NAME) !== undefined; const isNativeAbortSignal = isInstanceOfPrototype( value, - getAbortSignalPrototype(global) + abortSignalPrototype.value ); if (!hasAbortSymbol && !isNativeAbortSignal) return false; return reduceAbortBySymbol(value as AbortSignal, value as AbortHolder); @@ -3556,7 +3552,8 @@ export async function dehydrateWorkflowReturnValue( * not avoid, for callers that need them programmatically (e.g. a * retained-VM gate deciding whether the VM is still reusable). The * executions are emitted as span attributes either way, so omitting this - * loses nothing observability-wise. No runtime caller passes one yet. + * loses nothing observability-wise. The retained-VM gate in + * runtime/suspension-handler.ts passes one for step-input dehydration. */ guestCodeStatsOut?: GuestCodeStats ): Promise { diff --git a/packages/core/src/serialization/hardened.test.ts b/packages/core/src/serialization/hardened.test.ts index 04a45826fe..b00102facd 100644 --- a/packages/core/src/serialization/hardened.test.ts +++ b/packages/core/src/serialization/hardened.test.ts @@ -247,7 +247,9 @@ describe('hardened serialization: patched prototypes are never executed', () => expect(wire).toContain('boom'); expect(wire).not.toContain('hacked'); expect(vm.evaluate('globalThis.sideEffects')).toBe(0); - expect(stats.executions).toEqual([]); + // The only recorded execution is the lazy stack materialization — an + // engine-internal format-and-cache the retained-VM gate must see. + expect(stats.executions).toEqual([{ kind: 'getter', detail: 'stack' }]); }); }); @@ -421,9 +423,10 @@ describe('hardened serialization: unavoidable guest code is recorded', () => { bytes: new Uint8Array([1, 2, 3]), buffer: new ArrayBuffer(4), url: new URL('https://example.com/'), - error: new Error('boom'), sparse: [1, , 3], })`); + // (No Error in this set: serializing a native error reads its lazy + // `stack` accessor, which is recorded — see readErrorStack.) const { stats } = vm.serialize(value); @@ -566,7 +569,7 @@ describe('hardened serialization: wire-format parity', () => { ); expect(wire).toContain('DOMException'); - expect(stats.executions).toEqual([]); + expect(stats.executions).toEqual([{ kind: 'getter', detail: 'stack' }]); const revived = devalueCodec.deserialize( new TextEncoder().encode(wire), @@ -586,7 +589,11 @@ describe('hardened serialization: wire-format parity', () => { ); expect(wire).toContain('AggregateError'); - expect(stats.executions).toEqual([]); + expect(stats.executions).toEqual([ + { kind: 'getter', detail: 'stack' }, + { kind: 'getter', detail: 'stack' }, + { kind: 'getter', detail: 'stack' }, + ]); const revived = devalueCodec.deserialize( new TextEncoder().encode(wire), @@ -652,7 +659,10 @@ describe('hardened serialization: wire-format parity', () => { expect(wire).toContain('RetryableError'); expect(wire).toContain('1700000000000'); - expect(stats.executions).toEqual([{ kind: 'method', detail: 'getTime' }]); + expect(stats.executions).toEqual([ + { kind: 'getter', detail: 'stack' }, + { kind: 'method', detail: 'getTime' }, + ]); }); it('reads a real Date retryAfter without reporting anything', () => { @@ -667,7 +677,9 @@ describe('hardened serialization: wire-format parity', () => { const { wire, stats } = vm.serialize(error); expect(wire).toContain('1700000000000'); - expect(stats.executions).toEqual([]); + // Only the error's own lazy-stack read is recorded; the genuine Date + // reads through captured intrinsics. + expect(stats.executions).toEqual([{ kind: 'getter', detail: 'stack' }]); }); it('reports an accessor-valued Symbol.toStringTag', () => { diff --git a/packages/core/src/serialization/reducers/common.ts b/packages/core/src/serialization/reducers/common.ts index 6e4b1b7eda..b8d35f5d92 100644 --- a/packages/core/src/serialization/reducers/common.ts +++ b/packages/core/src/serialization/reducers/common.ts @@ -111,6 +111,29 @@ type SimpleErrorSubclassKey = { : never; }[keyof SerializableSpecial]; +/** + * Reads `error.stack` for serialization. A natural V8 error carries `stack` + * as an own *accessor* whose first invocation formats and caches the trace. + * That read is not passive: it executes `Error.prepareStackTrace` when the + * realm has one installed, and the format-and-cache itself is + * workflow-visible (a formatter installed later never runs for an + * already-materialized error). A cold replay repeats neither — it skips + * dehydration entirely — so the read is recorded as guest code. A + * data-property `stack` (rehydrated errors, workflow-assigned strings) + * reads passively. + */ +function readErrorStack(value: unknown): string | undefined { + const descriptor = Object.getOwnPropertyDescriptor(value as object, 'stack'); + if (descriptor && 'value' in descriptor) { + return descriptor.value as string | undefined; + } + recordGuestCode('getter', 'stack'); + if (descriptor?.get) { + return descriptor.get.call(value) as string | undefined; + } + return readProperty(value, 'stack') as string | undefined; +} + /** * Reduces any native Error instance to the shared `BaseErrorPayload` shape, * preserving `cause` only when present (to distinguish "no cause" from @@ -123,12 +146,12 @@ type SimpleErrorSubclassKey = { */ function reduceErrorBase(value: unknown): BaseErrorPayload | false { if (!types.isNativeError(value)) return false; - // `message`/`stack`/`cause` are own data properties on natural errors, so - // the descriptor-based reads cost nothing; a sandbox-defined accessor + // `message`/`cause` are own data properties on natural errors, so the + // descriptor-based reads cost nothing; a sandbox-defined accessor // (e.g. a getter on an Error subclass) is still invoked but recorded. const reduced: BaseErrorPayload = { message: readProperty(value, 'message') as string, - stack: readProperty(value, 'stack') as string | undefined, + stack: readErrorStack(value), }; if (hasProperty(value, 'cause')) reduced.cause = readProperty(value, 'cause'); return reduced; @@ -240,7 +263,7 @@ export function getCommonReducers( const reduced: SerializableSpecial['DOMException'] = { message: readProperty(value, 'message') as string, name: readProperty(value, 'name') as string, - stack: readProperty(value, 'stack') as string | undefined, + stack: readErrorStack(value), }; if (hasProperty(value, 'cause')) { reduced.cause = readProperty(value, 'cause'); @@ -350,7 +373,7 @@ export function getCommonReducers( const reduced: SerializableSpecial['Error'] = { name: readProperty(value, 'name') as string, message: readProperty(value, 'message') as string, - stack: readProperty(value, 'stack') as string | undefined, + stack: readErrorStack(value), }; if (hasProperty(value, 'cause')) { reduced.cause = readProperty(value, 'cause'); From 3f7f12f742569566f4ba7ed4084dd4440f502577 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:41:43 -0700 Subject: [PATCH 2/2] reword changeset and docs in plain language --- .changeset/retained-vm-guest-code-gate.md | 2 +- docs/content/docs/v5/configuration/runtime-tuning.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/retained-vm-guest-code-gate.md b/.changeset/retained-vm-guest-code-gate.md index 6ab0d16f66..d0f534b4e7 100644 --- a/.changeset/retained-vm-guest-code-gate.md +++ b/.changeset/retained-vm-guest-code-gate.md @@ -3,4 +3,4 @@ 'workflow': minor --- -Retained-VM boundaries now accept plain data and standard built-ins (`Map`, `Set`, `Date`, typed arrays, `URL`, `Headers`, …) as step inputs. The suspension handler gates retention on the hardened serializer's guest-code report: boundaries whose serialization executed workflow code or observable engine state (getters, proxies, custom serializers, `Error` stack materialization) fall back to ordinary replay. +Retained workflow VMs now keep the fast path when step arguments are plain data or standard built-ins (`Map`, `Set`, `Date`, typed arrays, `URL`, `Headers`, …), not just primitives. A boundary falls back to a normal replay only when serializing its arguments ran code the workflow controls — a getter, a proxy, a custom serializer — or computed an `Error`'s stack trace. diff --git a/docs/content/docs/v5/configuration/runtime-tuning.mdx b/docs/content/docs/v5/configuration/runtime-tuning.mdx index 24d50f9e8a..3feffb9613 100644 --- a/docs/content/docs/v5/configuration/runtime-tuning.mdx +++ b/docs/content/docs/v5/configuration/runtime-tuning.mdx @@ -101,7 +101,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Default: enabled - Keeps the suspended workflow VM alive across inline steps within one invocation, so each iteration of the inline loop appends only the newly written events instead of replaying the whole event log in a fresh VM. - Suspensions involving hooks, waits, or attributes — and any replay divergence — always fall back to a full replay. -- Step inputs made of plain data (objects, arrays, primitives) and standard built-ins (`Map`, `Set`, `Date`, `RegExp`, typed arrays, `ArrayBuffer`, `URL`, `Headers`) remain retainable: serialization reads them through captured intrinsics, so patching or polyfilling built-in prototypes does not affect retention. Anything whose serialization executes workflow code or perturbs engine state the workflow can observe — getters, proxies, custom class serializers, and `Error` instances (serializing one materializes its lazy `stack`) — falls back to replay for that boundary. +- Step inputs made of plain data (objects, arrays, primitives) and standard built-ins (`Map`, `Set`, `Date`, `RegExp`, typed arrays, `ArrayBuffer`, `URL`, `Headers`) keep the VM retained. Patching or polyfilling built-in prototypes doesn't change that: serialization never calls them. A boundary falls back to a full replay only when serializing its arguments runs code the workflow controls — a getter, a proxy, a custom class serializer — or computes an `Error`'s stack trace. - Set `0` or `false` to replay from scratch in a fresh VM on every iteration. ### `WORKFLOW_INLINE_OWNERSHIP`