Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/frozen-intrinsics-stack-trace-limit.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 4 additions & 5 deletions packages/effect/src/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<Reference<any>>
Object.setPrototypeOf(self, ServiceProto)
Expand Down
8 changes: 4 additions & 4 deletions packages/effect/src/Layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -2276,10 +2276,10 @@ const mockImpl = <I, S extends object>(service: Context.Key<I, S>, 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)
},
Expand Down
7 changes: 4 additions & 3 deletions packages/effect/src/LayerMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -383,10 +384,10 @@ export const Service = <Self>() =>
: 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<TagClass<Self, Id, string, any, any, any, any, any>>
Expand Down
22 changes: 11 additions & 11 deletions packages/effect/src/internal/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -314,9 +315,8 @@ export const causePrettyErrors = <E>(self: Cause.Cause<E>): Array<Error> => {
const interrupts: Array<Cause.Interrupt> = []
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") {
Expand All @@ -340,7 +340,7 @@ export const causePrettyErrors = <E>(self: Cause.Cause<E>): Array<Error> => {
errors.push(causePrettyError(error, interrupts[0].annotations))
}

;(Error as ErrorWithStackTraceLimit).stackTraceLimit = prevStackLimit
setStackTraceLimit(prevStackLimit)
return errors
}

Expand Down Expand Up @@ -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<Function>) =>
Expand Down Expand Up @@ -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)) :
Expand Down
63 changes: 63 additions & 0 deletions packages/effect/src/internal/stackTraceLimit.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
7 changes: 4 additions & 3 deletions packages/effect/src/internal/tracer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type * as Tracer from "../Tracer.ts"
import { getStackTraceLimit, setStackTraceLimit } from "./stackTraceLimit.ts"

export interface ErrorWithStackTraceLimit {
stackTraceLimit?: number | undefined
Expand All @@ -13,10 +14,10 @@ export const addSpanStackTrace = <A extends Tracer.TraceOptions>(
} 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)
Expand Down
7 changes: 4 additions & 3 deletions packages/effect/src/unstable/ai/Tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}

Expand Down
7 changes: 4 additions & 3 deletions packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<Self, any>()(id) {}
const self = Service as any
Expand Down
7 changes: 4 additions & 3 deletions packages/effect/src/unstable/rpc/RpcMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<AnyService>
Expand Down
60 changes: 60 additions & 0 deletions packages/effect/test/StackTraceLimit.test.ts
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
})