diff --git a/.changeset/frozen-intrinsics-stack-trace-limit.md b/.changeset/frozen-intrinsics-stack-trace-limit.md new file mode 100644 index 00000000000..b20cd5b9be4 --- /dev/null +++ b/.changeset/frozen-intrinsics-stack-trace-limit.md @@ -0,0 +1,8 @@ +--- +"effect": patch +"@effect/ai": patch +--- + +Avoid throwing when `Error.stackTraceLimit` is non-writable (frozen intrinsics / SES / deterministic sandboxes such as Temporal). + +Effect manipulates `Error.stackTraceLimit` in several internal spots to capture short or empty stack traces cheaply. In hardened environments where `Error` is frozen and `stackTraceLimit` is read-only, assigning to it throws, which broke Effect entirely. Stack-trace-limit manipulation is now best-effort and silently no-ops when the property cannot be modified, mirroring Node's own internal guard. Behavior in normal (writable) environments is unchanged. diff --git a/packages/ai/ai/src/Tool.ts b/packages/ai/ai/src/Tool.ts index d79ce3ec0b3..34c92a3429c 100644 --- a/packages/ai/ai/src/Tool.ts +++ b/packages/ai/ai/src/Tool.ts @@ -1506,6 +1506,22 @@ function filter(obj: any) { return obj } +// Utility for safely manipulating `Error.stackTraceLimit` in environments where +// intrinsics may be frozen (e.g., SES / hardened JavaScript). Duplicated here +// because `effect`'s internal helper is not part of its public API. When the +// property is non-writable, mutating it throws, so this degrades to a no-op. +const isStackTraceLimitWritable = (): boolean => { + const desc = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit") + if (desc === undefined) { + return Object.isExtensible(Error) + } + return Object.prototype.hasOwnProperty.call(desc, "writable") + ? desc.writable === true + : desc.set !== undefined +} + +const canWriteStackTraceLimit = isStackTraceLimitWritable() + /** * **Unsafe**: This function will throw an error if an insecure property is * found in the parsed JSON or if the provided JSON text is not parseable. @@ -1515,12 +1531,15 @@ function filter(obj: any) { */ export const unsafeSecureJsonParse = (text: string): unknown => { // Performance optimization, see https://github.com/fastify/secure-json-parse/pull/90 - const { stackTraceLimit } = Error + if (!canWriteStackTraceLimit) { + return _parse(text) + } + const prevLimit = Error.stackTraceLimit Error.stackTraceLimit = 0 try { return _parse(text) } finally { - Error.stackTraceLimit = stackTraceLimit + Error.stackTraceLimit = prevLimit } } diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index d9143c239b6..4901e0d54b9 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -40,6 +40,7 @@ import * as option_ from "./internal/option.js" import * as query from "./internal/query.js" import * as runtime_ from "./internal/runtime.js" import * as schedule_ from "./internal/schedule.js" +import * as StackTraceLimit from "./internal/stackTraceLimit.js" import * as internalTracer from "./internal/tracer.js" import type * as Layer from "./Layer.js" import type * as LogLevel from "./LogLevel.js" @@ -13515,10 +13516,10 @@ export const Tag: (id: Id) => < : [X] extends [PromiseLike] ? Effect : Effect } = (id) => () => { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 2 + const limit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const creationError = new Error() - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limit) function TagClass() {} Object.setPrototypeOf(TagClass, TagProto) TagClass.key = id @@ -13674,10 +13675,10 @@ export const Service: () => [Self] extends [never] ? MissingSelfGe return function() { const [id, maker] = arguments const proxy = "accessors" in maker ? maker["accessors"] : false - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 2 + const limit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const creationError = new Error() - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limit) let patchState: "unchecked" | "plain" | "patched" = "unchecked" const TagClass: any = function(this: any, service: any) { @@ -14634,16 +14635,16 @@ export const fn: name: string, options?: Tracer.SpanOptions ) => fn.Gen & fn.NonGen) = function(nameOrBody: Function | string, ...pipeables: Array) { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 2 + const limitDef = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const errorDef = new Error() - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limitDef) if (typeof nameOrBody !== "string") { return defineLength(nameOrBody.length, function(this: any, ...args: Array) { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 2 + const limitCall = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const errorCall = new Error() - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limitCall) return fnApply({ self: this, body: nameOrBody, @@ -14665,10 +14666,10 @@ export const fn: body.length, ({ [name](this: any, ...args: Array) { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 2 + const limitCall = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const errorCall = new Error() - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limitCall) return fnApply({ self: this, body, diff --git a/packages/effect/src/LayerMap.ts b/packages/effect/src/LayerMap.ts index 15deb394ae5..5c635d20560 100644 --- a/packages/effect/src/LayerMap.ts +++ b/packages/effect/src/LayerMap.ts @@ -8,6 +8,7 @@ import * as Effect from "./Effect.js" import * as FiberRefsPatch from "./FiberRefsPatch.js" import { identity } from "./Function.js" import * as core from "./internal/core.js" +import * as StackTraceLimit from "./internal/stackTraceLimit.js" import * as Layer from "./Layer.js" import * as RcMap from "./RcMap.js" import * as Runtime from "./Runtime.js" @@ -357,10 +358,10 @@ export const Service = () => Options extends { readonly dependencies: ReadonlyArray } ? Options["dependencies"][number] : never > => { const Err = globalThis.Error as any - const limit = Err.stackTraceLimit - Err.stackTraceLimit = 2 + const limit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const creationError = new Err() - Err.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limit) function TagClass() {} const TagClass_ = TagClass as any as Mutable> diff --git a/packages/effect/src/Micro.ts b/packages/effect/src/Micro.ts index da290c29785..6d8fdc30338 100644 --- a/packages/effect/src/Micro.ts +++ b/packages/effect/src/Micro.ts @@ -21,6 +21,7 @@ import { format, NodeInspectSymbol, toStringUnknown } from "./Inspectable.js" import * as InternalContext from "./internal/context.js" import * as doNotation from "./internal/doNotation.js" import { StructuralPrototype } from "./internal/effectable.js" +import * as StackTraceLimit from "./internal/stackTraceLimit.js" import * as Option from "./Option.js" import type { Pipeable } from "./Pipeable.js" import { pipeArguments } from "./Pipeable.js" @@ -2984,10 +2985,10 @@ export const withTrace: { (name: string): (self: Micro) => Micro (self: Micro, name: string): Micro } = function() { - const prevLimit = globalThis.Error.stackTraceLimit - globalThis.Error.stackTraceLimit = 2 + const prevLimit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const error = new globalThis.Error() - globalThis.Error.stackTraceLimit = prevLimit + StackTraceLimit.setStackTraceLimit(prevLimit) function generate(name: string, cause: MicroCause) { const stack = error.stack if (!stack) { diff --git a/packages/effect/src/internal/cause.ts b/packages/effect/src/internal/cause.ts index e557a914235..bee8e4911a7 100644 --- a/packages/effect/src/internal/cause.ts +++ b/packages/effect/src/internal/cause.ts @@ -17,6 +17,7 @@ import type { AnySpan, Span } from "../Tracer.js" import type * as Types from "../Types.js" import { getBugErrorMessage } from "./errors.js" import * as OpCodes from "./opCodes/cause.js" +import * as StackTraceLimit from "./stackTraceLimit.js" // ----------------------------------------------------------------------------- // Models @@ -898,19 +899,19 @@ const renderErrorCause = (cause: Cause.PrettyError, prefix: string) => { /** @internal */ export const makePrettyError = (originalError: unknown): Cause.PrettyError => { const originalErrorIsObject = typeof originalError === "object" && originalError !== null - const prevLimit = Error.stackTraceLimit - Error.stackTraceLimit = 1 + const prevLimit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(1) const error = new Error( prettyErrorMessage(originalError), originalErrorIsObject && "cause" in originalError && typeof originalError.cause !== "undefined" ? { cause: makePrettyError(originalError.cause) } : undefined ) as Types.Mutable - Error.stackTraceLimit = prevLimit + StackTraceLimit.setStackTraceLimit(prevLimit) if (error.message === "") { error.message = "An error has occurred" } - Error.stackTraceLimit = prevLimit + StackTraceLimit.setStackTraceLimit(prevLimit) error.name = originalError instanceof Error ? originalError.name : "Error" if (originalErrorIsObject) { if (spanSymbol in originalError) { diff --git a/packages/effect/src/internal/context.ts b/packages/effect/src/internal/context.ts index e77c1cd6634..d99353c284c 100644 --- a/packages/effect/src/internal/context.ts +++ b/packages/effect/src/internal/context.ts @@ -12,6 +12,7 @@ import type * as STM from "../STM.js" import type { NoInfer } from "../Types.js" import { EffectPrototype, effectVariance } from "./effectable.js" import * as option from "./option.js" +import * as StackTraceLimit from "./stackTraceLimit.js" /** @internal */ export const TagTypeId: C.TagTypeId = Symbol.for("effect/Context/Tag") as C.TagTypeId @@ -67,10 +68,10 @@ export const ReferenceProto: any = { /** @internal */ export const makeGenericTag = (key: string): C.Tag => { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 2 + const limit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const creationError = new Error() - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limit) const tag = Object.create(TagProto) Object.defineProperty(tag, "stack", { get() { @@ -83,10 +84,10 @@ export const makeGenericTag = (key: string): C /** @internal */ export const Tag = (id: Id) => (): C.TagClass => { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 2 + const limit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const creationError = new Error() - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limit) function TagClass() {} Object.setPrototypeOf(TagClass, TagProto) @@ -104,10 +105,10 @@ export const Reference = () => (id: Id, options: { readonly defaultValue: () => Service }): C.ReferenceClass => { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 2 + const limit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const creationError = new Error() - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limit) function ReferenceClass() {} Object.setPrototypeOf(ReferenceClass, ReferenceProto) diff --git a/packages/effect/src/internal/core-effect.ts b/packages/effect/src/internal/core-effect.ts index 0ef68fb67b6..6b165d299a0 100644 --- a/packages/effect/src/internal/core-effect.ts +++ b/packages/effect/src/internal/core-effect.ts @@ -38,6 +38,7 @@ import * as fiberRefsPatch from "./fiberRefs/patch.js" import type { FiberRuntime } from "./fiberRuntime.js" import * as metricLabel from "./metric/label.js" import * as runtimeFlags from "./runtimeFlags.js" +import * as StackTraceLimit from "./stackTraceLimit.js" import * as internalTracer from "./tracer.js" /* @internal */ @@ -2252,10 +2253,10 @@ export const functionWithSpan = , Ret extends Effect.Eff (function(this: any) { let captureStackTrace: LazyArg | boolean = options.captureStackTrace ?? false if (options.captureStackTrace !== false) { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 2 + const limit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const error = new Error() - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limit) let cache: false | string = false captureStackTrace = () => { if (cache !== false) { diff --git a/packages/effect/src/internal/layer.ts b/packages/effect/src/internal/layer.ts index 685766c3291..dd81cb350ac 100644 --- a/packages/effect/src/internal/layer.ts +++ b/packages/effect/src/internal/layer.ts @@ -33,6 +33,7 @@ import * as OpCodes from "./opCodes/layer.js" import * as ref from "./ref.js" import * as runtime from "./runtime.js" import * as runtimeFlags from "./runtimeFlags.js" +import * as StackTraceLimit from "./stackTraceLimit.js" import * as synchronized from "./synchronizedRef.js" import * as tracer from "./tracer.js" @@ -693,10 +694,10 @@ const mockImpl = (tag: Context.Tag, service: Layer.Pa if (prop in target) { return target[prop as keyof S] } - const prevLimit = Error.stackTraceLimit - Error.stackTraceLimit = 2 + const prevLimit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(2) const error = new Error(`${tag.key}: Unimplemented method "${prop.toString()}"`) - Error.stackTraceLimit = prevLimit + StackTraceLimit.setStackTraceLimit(prevLimit) error.name = "UnimplementedError" return makeUnimplemented(error) }, diff --git a/packages/effect/src/internal/runtime.ts b/packages/effect/src/internal/runtime.ts index dd4b69bf2d0..511db81b871 100644 --- a/packages/effect/src/internal/runtime.ts +++ b/packages/effect/src/internal/runtime.ts @@ -24,6 +24,7 @@ import * as FiberRuntime from "./fiberRuntime.js" import * as fiberScope from "./fiberScope.js" import * as OpCodes from "./opCodes/effect.js" import * as runtimeFlags from "./runtimeFlags.js" +import * as StackTraceLimit from "./stackTraceLimit.js" import * as supervisor_ from "./supervisor.js" const makeDual = , Return>( @@ -179,10 +180,10 @@ class AsyncFiberExceptionImpl extends Error implements Runtime.Asy } const asyncFiberException = (fiber: Fiber.RuntimeFiber): Runtime.AsyncFiberException => { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 0 + const limit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(0) const error = new AsyncFiberExceptionImpl(fiber) - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limit) return error } @@ -230,10 +231,10 @@ class FiberFailureImpl extends Error implements Runtime.FiberFailure { /** @internal */ export const fiberFailure = (cause: Cause.Cause): Runtime.FiberFailure => { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 0 + const limit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(0) const error = new FiberFailureImpl(cause) - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limit) return error } diff --git a/packages/effect/src/internal/stackTraceLimit.ts b/packages/effect/src/internal/stackTraceLimit.ts new file mode 100644 index 00000000000..2031604e794 --- /dev/null +++ b/packages/effect/src/internal/stackTraceLimit.ts @@ -0,0 +1,60 @@ +/** + * Utility for safely manipulating `Error.stackTraceLimit` in environments + * where intrinsics may be frozen (e.g., SES / hardened JavaScript or + * deterministic sandboxes). When the property is non-writable, mutating it + * throws, so all manipulation here degrades to a best-effort, silent no-op. + * + * Mirrors the guard Node uses internally: + * https://github.com/nodejs/node/blob/e77694631f1642c302f664703197b5aabc65b482/lib/internal/errors.js#L246 + * + * The error is constructed inline at each call site (rather than inside a + * closure here) so the captured stack trace keeps pointing at the real caller + * instead of this module. + * + * @internal + */ +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty +const ObjectIsExtensible = Object.isExtensible + +/** + * Check if `Error.stackTraceLimit` is writable. + * Returns `false` if the property is frozen, non-writable, or `Error` is non-extensible. + * + * @internal + */ +export const isStackTraceLimitWritable = (): boolean => { + const desc = ObjectGetOwnPropertyDescriptor(Error, "stackTraceLimit") + if (desc === undefined) { + return ObjectIsExtensible(Error) + } + + return ObjectPrototypeHasOwnProperty.call(desc, "writable") + ? desc.writable === true + : desc.set !== undefined +} + +// Cache the check result since it won't change during runtime +const canWriteStackTraceLimit = isStackTraceLimitWritable() + +/** + * Get the current `Error.stackTraceLimit` value. + * Returns `undefined` if the property doesn't exist. + * + * @internal + */ +export const getStackTraceLimit = (): number | undefined => Error.stackTraceLimit + +/** + * Safely set `Error.stackTraceLimit` if possible, otherwise no-op. + * + * Accepts `undefined` so a value read via {@link getStackTraceLimit} can be + * restored faithfully. + * + * @internal + */ +export const setStackTraceLimit = (value: number | undefined): void => { + if (canWriteStackTraceLimit) { + Error.stackTraceLimit = value as number + } +} diff --git a/packages/effect/src/internal/tracer.ts b/packages/effect/src/internal/tracer.ts index ae37dc23b37..ecd667d6041 100644 --- a/packages/effect/src/internal/tracer.ts +++ b/packages/effect/src/internal/tracer.ts @@ -6,6 +6,7 @@ import type * as Exit from "../Exit.js" import { constFalse } from "../Function.js" import type * as Option from "../Option.js" import type * as Tracer from "../Tracer.js" +import * as StackTraceLimit from "./stackTraceLimit.js" /** @internal */ export const TracerTypeId: Tracer.TracerTypeId = Symbol.for("effect/Tracer") as Tracer.TracerTypeId @@ -122,10 +123,10 @@ export const addSpanStackTrace = (options: Tracer.SpanOptions | undefined): Trac } else if (options?.captureStackTrace !== undefined && typeof options.captureStackTrace !== "boolean") { return options } - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 3 + const limit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(3) const traceError = new Error() - Error.stackTraceLimit = limit + StackTraceLimit.setStackTraceLimit(limit) let cache: false | string = false return { ...options, diff --git a/packages/effect/test/StackTraceLimit.test.ts b/packages/effect/test/StackTraceLimit.test.ts new file mode 100644 index 00000000000..f7ba136e870 --- /dev/null +++ b/packages/effect/test/StackTraceLimit.test.ts @@ -0,0 +1,58 @@ +import { describe, it, vi } from "@effect/vitest" +import { assertFalse, assertTrue, strictEqual } from "@effect/vitest/utils" +import * as StackTraceLimit from "../src/internal/stackTraceLimit.js" + +describe("stackTraceLimit", () => { + describe("writable environment", () => { + it("isStackTraceLimitWritable returns true", () => { + assertTrue(StackTraceLimit.isStackTraceLimitWritable()) + }) + + it("getStackTraceLimit reflects the current value", () => { + const prev = Error.stackTraceLimit + StackTraceLimit.setStackTraceLimit(5) + strictEqual(StackTraceLimit.getStackTraceLimit(), 5) + StackTraceLimit.setStackTraceLimit(prev) + }) + + it("setStackTraceLimit updates and restores the limit", () => { + const prev = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(7) + strictEqual(Error.stackTraceLimit, 7) + StackTraceLimit.setStackTraceLimit(prev) + strictEqual(Error.stackTraceLimit, prev) + }) + }) + + describe("frozen intrinsics (non-writable Error.stackTraceLimit)", () => { + // The writability check is cached at module load, so re-import the module + // after redefining the property to exercise the frozen path. + it("degrades to a no-op without throwing", async () => { + const original = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit") + Object.defineProperty(Error, "stackTraceLimit", { + value: 10, + writable: false, + configurable: true, + enumerable: original?.enumerable ?? false + }) + try { + vi.resetModules() + const frozen = await import("../src/internal/stackTraceLimit.js") + + assertFalse(frozen.isStackTraceLimitWritable()) + + // reading still works + strictEqual(frozen.getStackTraceLimit(), 10) + + // setStackTraceLimit is a silent no-op rather than throwing + frozen.setStackTraceLimit(0) + strictEqual(Error.stackTraceLimit, 10) + } finally { + if (original !== undefined) { + Object.defineProperty(Error, "stackTraceLimit", original) + } + vi.resetModules() + } + }) + }) +})