From 3d7eaf6109f8cc1a7c32de2e85b9bc14a70ea110 Mon Sep 17 00:00:00 2001 From: Ben Weis Date: Fri, 19 Jun 2026 17:19:28 -0400 Subject: [PATCH] fix: support frozen intrinsics when adjusting Error.stackTraceLimit In hardened/deterministic JavaScript environments (SES "frozen intrinsics", Temporal-style sandboxes), `Error` is frozen and `Error.stackTraceLimit` is read-only. Effect mutates `stackTraceLimit` in several internal spots to capture short/empty stack traces cheaply; under frozen intrinsics those assignments throw, breaking Effect entirely. Add an internal `internal/stackTraceLimit.ts` helper (mirroring Node's own guard) that detects writability once at module load and degrades `set` to a silent no-op when the property can't be modified. Replace the raw mutations across Context.ts, Layer.ts, LayerMap.ts, internal/effect.ts, internal/tracer.ts, HttpApiMiddleware.ts and RpcMiddleware.ts with guarded get/set calls, keeping `new Error()` inline at each call site so captured stack traces still point at the real caller. `unsafeSecureJsonParse` in unstable/ai/Tool.ts imports the same internal helper directly (it lives in the same package here, unlike `@effect/ai` upstream). Ports Effect-TS/effect#6279 to effect-smol. Original change stood up by @arlyon. Co-Authored-By: Claude Opus 4.8 --- .../frozen-intrinsics-stack-trace-limit.md | 7 +++ packages/effect/src/Context.ts | 9 ++- packages/effect/src/Layer.ts | 8 +-- packages/effect/src/LayerMap.ts | 7 ++- packages/effect/src/internal/effect.ts | 22 +++---- .../effect/src/internal/stackTraceLimit.ts | 63 +++++++++++++++++++ packages/effect/src/internal/tracer.ts | 7 ++- packages/effect/src/unstable/ai/Tool.ts | 7 ++- .../src/unstable/httpapi/HttpApiMiddleware.ts | 7 ++- .../effect/src/unstable/rpc/RpcMiddleware.ts | 7 ++- packages/effect/test/StackTraceLimit.test.ts | 60 ++++++++++++++++++ 11 files changed, 169 insertions(+), 35 deletions(-) create mode 100644 .changeset/frozen-intrinsics-stack-trace-limit.md create mode 100644 packages/effect/src/internal/stackTraceLimit.ts create mode 100644 packages/effect/test/StackTraceLimit.test.ts diff --git a/.changeset/frozen-intrinsics-stack-trace-limit.md b/.changeset/frozen-intrinsics-stack-trace-limit.md new file mode 100644 index 0000000000..04aa3ce467 --- /dev/null +++ b/.changeset/frozen-intrinsics-stack-trace-limit.md @@ -0,0 +1,7 @@ +--- +"effect": 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/effect/src/Context.ts b/packages/effect/src/Context.ts index 5548894c21..cfc8cd983a 100644 --- a/packages/effect/src/Context.ts +++ b/packages/effect/src/Context.ts @@ -17,7 +17,7 @@ import { dual, type LazyArg } from "./Function.ts" import * as Hash from "./Hash.ts" import type { Inspectable } from "./Inspectable.ts" import { exitSucceed, PipeInspectableProto, withFiber } from "./internal/core.ts" -import type { ErrorWithStackTraceLimit } from "./internal/tracer.ts" +import { getStackTraceLimit, setStackTraceLimit } from "./internal/stackTraceLimit.ts" import * as Option from "./Option.ts" import type { Pipeable } from "./Pipeable.ts" import { hasProperty } from "./Predicate.ts" @@ -231,11 +231,10 @@ export const Service: { > & { readonly make: Make } } = function() { - const prevLimit = (Error as ErrorWithStackTraceLimit).stackTraceLimit - ;(Error as ErrorWithStackTraceLimit) - .stackTraceLimit = 2 + const prevLimit = getStackTraceLimit() + setStackTraceLimit(2) const err = new Error() - ;(Error as ErrorWithStackTraceLimit).stackTraceLimit = prevLimit + setStackTraceLimit(prevLimit) function KeyClass() {} const self = KeyClass as any as Types.Mutable> Object.setPrototypeOf(self, ServiceProto) diff --git a/packages/effect/src/Layer.ts b/packages/effect/src/Layer.ts index 35b85f5254..3b590a5092 100644 --- a/packages/effect/src/Layer.ts +++ b/packages/effect/src/Layer.ts @@ -20,7 +20,7 @@ import type { LazyArg } from "./Function.ts" import { constant, constTrue, constUndefined, dual, identity } from "./Function.ts" import * as core from "./internal/core.ts" import * as internalEffect from "./internal/effect.ts" -import type { ErrorWithStackTraceLimit } from "./internal/tracer.ts" +import { getStackTraceLimit, setStackTraceLimit } from "./internal/stackTraceLimit.ts" import * as internalTracer from "./internal/tracer.ts" import { type Pipeable, pipeArguments } from "./Pipeable.ts" import { hasProperty } from "./Predicate.ts" @@ -2276,10 +2276,10 @@ const mockImpl = (service: Context.Key, implementatio if (prop in target) { return target[prop as keyof S] } - const prevLimit = (Error as ErrorWithStackTraceLimit).stackTraceLimit - ;(Error as ErrorWithStackTraceLimit).stackTraceLimit = 2 + const prevLimit = getStackTraceLimit() + setStackTraceLimit(2) const error = new Error(`${service.key}: Unimplemented method "${prop.toString()}"`) - ;(Error as ErrorWithStackTraceLimit).stackTraceLimit = prevLimit + setStackTraceLimit(prevLimit) error.name = "UnimplementedError" return makeUnimplemented(error) }, diff --git a/packages/effect/src/LayerMap.ts b/packages/effect/src/LayerMap.ts index 410c04c43e..b4fc9800d8 100644 --- a/packages/effect/src/LayerMap.ts +++ b/packages/effect/src/LayerMap.ts @@ -13,6 +13,7 @@ import * as Context from "./Context.ts" import type * as Duration from "./Duration.ts" import * as Effect from "./Effect.ts" import { identity } from "./Function.ts" +import { getStackTraceLimit, setStackTraceLimit } from "./internal/stackTraceLimit.ts" import * as Layer from "./Layer.ts" import * as RcMap from "./RcMap.ts" import * as Scope from "./Scope.ts" @@ -383,10 +384,10 @@ export const Service = () => : never > => { const Err = globalThis.Error as any - const limit = Err.stackTraceLimit - Err.stackTraceLimit = 2 + const limit = getStackTraceLimit() + setStackTraceLimit(2) const creationError = new Err() - Err.stackTraceLimit = limit + setStackTraceLimit(limit) function TagClass() {} const TagClass_ = TagClass as any as Mutable> diff --git a/packages/effect/src/internal/effect.ts b/packages/effect/src/internal/effect.ts index 676250f743..80a8f0cf2d 100644 --- a/packages/effect/src/internal/effect.ts +++ b/packages/effect/src/internal/effect.ts @@ -96,7 +96,8 @@ import { TracerSpanLinks, TracerTimingEnabled } from "./references.ts" -import { addSpanStackTrace, type ErrorWithStackTraceLimit, makeStackCleaner } from "./tracer.ts" +import { getStackTraceLimit, setStackTraceLimit } from "./stackTraceLimit.ts" +import { addSpanStackTrace, makeStackCleaner } from "./tracer.ts" import { version } from "./version.ts" // ---------------------------------------------------------------------------- @@ -314,9 +315,8 @@ export const causePrettyErrors = (self: Cause.Cause): Array => { const interrupts: Array = [] if (self.reasons.length === 0) return errors - const prevStackLimit = (Error as ErrorWithStackTraceLimit).stackTraceLimit - ;(Error as ErrorWithStackTraceLimit) - .stackTraceLimit = 1 + const prevStackLimit = getStackTraceLimit() + setStackTraceLimit(1) for (const failure of self.reasons) { if (failure._tag === "Interrupt") { @@ -340,7 +340,7 @@ export const causePrettyErrors = (self: Cause.Cause): Array => { errors.push(causePrettyError(error, interrupts[0].annotations)) } - ;(Error as ErrorWithStackTraceLimit).stackTraceLimit = prevStackLimit + setStackTraceLimit(prevStackLimit) return errors } @@ -1157,10 +1157,10 @@ export const fn: typeof Effect.fn = function() { const name = nameFirst ? arguments[0] : "Effect.fn" const spanOptions = nameFirst ? arguments[1] : undefined - const prevLimit = globalThis.Error.stackTraceLimit - globalThis.Error.stackTraceLimit = 2 + const prevLimit = getStackTraceLimit() + setStackTraceLimit(2) const defError = new globalThis.Error() - globalThis.Error.stackTraceLimit = prevLimit + setStackTraceLimit(prevLimit) if (nameFirst) { return (body: Function | { readonly self: any }, ...pipeables: Array) => @@ -1200,10 +1200,10 @@ const makeFn = ( if (!isEffect(result)) { return result } - const prevLimit = globalThis.Error.stackTraceLimit - globalThis.Error.stackTraceLimit = 2 + const prevLimit = getStackTraceLimit() + setStackTraceLimit(2) const callError = new globalThis.Error() - globalThis.Error.stackTraceLimit = prevLimit + setStackTraceLimit(prevLimit) return updateService( addSpan ? useSpan(name, spanOptions!, (span) => provideParentSpan(result, span)) : diff --git a/packages/effect/src/internal/stackTraceLimit.ts b/packages/effect/src/internal/stackTraceLimit.ts new file mode 100644 index 0000000000..440e18044d --- /dev/null +++ b/packages/effect/src/internal/stackTraceLimit.ts @@ -0,0 +1,63 @@ +/** + * Utility for safely manipulating `Error.stackTraceLimit` in environments + * where intrinsics may be frozen (e.g., SES / hardened JavaScript or + * deterministic sandboxes such as Temporal). 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 + */ +import type { ErrorWithStackTraceLimit } from "./tracer.ts" + +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 as ErrorWithStackTraceLimit).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 as ErrorWithStackTraceLimit).stackTraceLimit = value + } +} diff --git a/packages/effect/src/internal/tracer.ts b/packages/effect/src/internal/tracer.ts index dce0d13102..fe9e84a44b 100644 --- a/packages/effect/src/internal/tracer.ts +++ b/packages/effect/src/internal/tracer.ts @@ -1,4 +1,5 @@ import type * as Tracer from "../Tracer.ts" +import { getStackTraceLimit, setStackTraceLimit } from "./stackTraceLimit.ts" export interface ErrorWithStackTraceLimit { stackTraceLimit?: number | undefined @@ -13,10 +14,10 @@ export const addSpanStackTrace = ( } else if (options?.captureStackTrace !== undefined && typeof options.captureStackTrace !== "boolean") { return options } - const limit = (Error as ErrorWithStackTraceLimit).stackTraceLimit - ;(Error as ErrorWithStackTraceLimit).stackTraceLimit = 3 + const limit = getStackTraceLimit() + setStackTraceLimit(3) const traceError = new Error() - ;(Error as ErrorWithStackTraceLimit).stackTraceLimit = limit + setStackTraceLimit(limit) return { ...options, captureStackTrace: spanCleaner(() => traceError.stack) diff --git a/packages/effect/src/unstable/ai/Tool.ts b/packages/effect/src/unstable/ai/Tool.ts index 9c86bc6769..9e8d034170 100644 --- a/packages/effect/src/unstable/ai/Tool.ts +++ b/packages/effect/src/unstable/ai/Tool.ts @@ -14,6 +14,7 @@ import * as Context from "../../Context.ts" import type * as Effect from "../../Effect.ts" import { constFalse, constTrue, identity } from "../../Function.ts" +import * as StackTraceLimit from "../../internal/stackTraceLimit.ts" import type * as JsonSchema from "../../JsonSchema.ts" import { pipeArguments } from "../../Pipeable.ts" import * as Predicate from "../../Predicate.ts" @@ -1956,12 +1957,12 @@ 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 - Error.stackTraceLimit = 0 + const prevLimit = StackTraceLimit.getStackTraceLimit() + StackTraceLimit.setStackTraceLimit(0) try { return _parse(text) } finally { - Error.stackTraceLimit = stackTraceLimit + StackTraceLimit.setStackTraceLimit(prevLimit) } } diff --git a/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts b/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts index 2773142925..5ad9464752 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts @@ -15,6 +15,7 @@ /** @effect-diagnostics classSelfMismatch:off */ import * as Context from "../../Context.ts" import * as Effect from "../../Effect.ts" +import { getStackTraceLimit, setStackTraceLimit } from "../../internal/stackTraceLimit.ts" import * as Layer from "../../Layer.ts" import { hasProperty } from "../../Predicate.ts" import type * as Schema from "../../Schema.ts" @@ -352,10 +353,10 @@ export const Service = < } | undefined ) => { const Err = globalThis.Error as any - const limit = Err.stackTraceLimit - Err.stackTraceLimit = 2 + const limit = getStackTraceLimit() + setStackTraceLimit(2) const creationError = new Err() - Err.stackTraceLimit = limit + setStackTraceLimit(limit) class Service extends Context.Service()(id) {} const self = Service as any diff --git a/packages/effect/src/unstable/rpc/RpcMiddleware.ts b/packages/effect/src/unstable/rpc/RpcMiddleware.ts index 6ce87f8371..bdd1b3b2f3 100644 --- a/packages/effect/src/unstable/rpc/RpcMiddleware.ts +++ b/packages/effect/src/unstable/rpc/RpcMiddleware.ts @@ -11,6 +11,7 @@ */ import * as Context from "../../Context.ts" import * as Effect from "../../Effect.ts" +import { getStackTraceLimit, setStackTraceLimit } from "../../internal/stackTraceLimit.ts" import * as Layer from "../../Layer.ts" import * as Schema from "../../Schema.ts" import { Scope } from "../../Scope.ts" @@ -292,10 +293,10 @@ export const Service = < } ) => { const Err = globalThis.Error as any - const limit = Err.stackTraceLimit - Err.stackTraceLimit = 2 + const limit = getStackTraceLimit() + setStackTraceLimit(2) const creationError = new Err() - Err.stackTraceLimit = limit + setStackTraceLimit(limit) function ServiceClass() {} const ServiceClass_ = ServiceClass as any as Mutable diff --git a/packages/effect/test/StackTraceLimit.test.ts b/packages/effect/test/StackTraceLimit.test.ts new file mode 100644 index 0000000000..e4f9f89a69 --- /dev/null +++ b/packages/effect/test/StackTraceLimit.test.ts @@ -0,0 +1,60 @@ +import { describe, it, vi } from "@effect/vitest" +import * as StackTraceLimit from "effect/internal/stackTraceLimit" +import { assertFalse, assertTrue, strictEqual } from "./utils/assert.ts" + +const getLimit = (): number | undefined => (Error as { stackTraceLimit?: number | undefined }).stackTraceLimit + +describe("stackTraceLimit", () => { + describe("writable environment", () => { + it("isStackTraceLimitWritable returns true", () => { + assertTrue(StackTraceLimit.isStackTraceLimitWritable()) + }) + + it("getStackTraceLimit reflects the current value", () => { + const prev = getLimit() + 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(getLimit(), 7) + StackTraceLimit.setStackTraceLimit(prev) + strictEqual(getLimit(), 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("effect/internal/stackTraceLimit") + + assertFalse(frozen.isStackTraceLimitWritable()) + + // reading still works + strictEqual(frozen.getStackTraceLimit(), 10) + + // setStackTraceLimit is a silent no-op rather than throwing + frozen.setStackTraceLimit(0) + strictEqual(getLimit(), 10) + } finally { + if (original !== undefined) { + Object.defineProperty(Error, "stackTraceLimit", original) + } + vi.resetModules() + } + }) + }) +})