Skip to content

chore(deps): update dependency effect to v3.20.0 [security]#553

Open
renovate[bot] wants to merge 1 commit intomainfrom
renovate/npm-effect-vulnerability
Open

chore(deps): update dependency effect to v3.20.0 [security]#553
renovate[bot] wants to merge 1 commit intomainfrom
renovate/npm-effect-vulnerability

Conversation

@renovate
Copy link
Contributor

@renovate renovate bot commented Mar 20, 2026

This PR contains the following updates:

Package Change Age Confidence
effect (source) 3.19.33.20.0 age confidence

GitHub Vulnerability Alerts

CVE-2026-32887

Versions

  • effect: 3.19.15
  • @effect/rpc: 0.72.1
  • @effect/platform: 0.94.2
  • Node.js: v22.20.0
  • Vercel runtime with Fluid compute
  • Next.js: 16 (App Router)
  • @clerk/nextjs: 6.x

Root cause

Effect's MixedScheduler batches fiber continuations and drains them inside a single microtask or timer callback. The AsyncLocalStorage context active during that callback belongs to whichever request first triggered the scheduler's drain cycle — not the request that owns the fiber being resumed.

Detailed mechanism

1. Scheduler batching (effect/src/Scheduler.ts, MixedScheduler)

// MixedScheduler.starve() — called once when first task is scheduled
private starve(depth = 0) {
  if (depth >= this.maxNextTickBeforeTimer) {
    setTimeout(() => this.starveInternal(0), 0)       // timer queue
  } else {
    Promise.resolve(void 0).then(() => this.starveInternal(depth + 1)) // microtask queue
  }
}

// MixedScheduler.starveInternal() — drains ALL accumulated tasks in one call
private starveInternal(depth: number) {
  const tasks = this.tasks.buckets
  this.tasks.buckets = []
  for (const [_, toRun] of tasks) {
    for (let i = 0; i < toRun.length; i++) {
      toRun[i]()  // ← Every fiber continuation runs in the SAME ALS context
    }
  }
  // ...
}

scheduleTask only calls starve() when running is false. Subsequent tasks accumulate in this.tasks until starveInternal drains them all. The Promise.then() (or setTimeout) callback inherits the ALS context from whichever call site created it — i.e., whichever request's fiber first set running = true.

Result: Under concurrent load, fiber continuations from Request A and Request B execute inside the same starveInternal call, sharing a single ALS context. If Request A triggered starve(), then Request B's fiber reads Request A's ALS context.

2. toWebHandlerRuntime does not propagate ALS (@effect/platform/src/HttpApp.ts:211-240)

export const toWebHandlerRuntime = <R>(runtime: Runtime.Runtime<R>) => {
  const httpRuntime: Types.Mutable<Runtime.Runtime<R>> = Runtime.make(runtime)
  const run = Runtime.runFork(httpRuntime)
  return <E>(self: Default<E, R | Scope.Scope>, middleware?) => {
    return (request: Request, context?): Promise<Response> =>
      new Promise((resolve) => {
        // Per-request Effect context is correctly set via contextMap:
        const contextMap = new Map<string, any>(runtime.context.unsafeMap)
        const httpServerRequest = ServerRequest.fromWeb(request)
        contextMap.set(ServerRequest.HttpServerRequest.key, httpServerRequest)
        httpRuntime.context = Context.unsafeMake(contextMap)

        // But the fiber is forked without any ALS propagation:
        const fiber = run(httpApp as any)  // ← ALS context is NOT captured or restored
      })
  }
}

Effect's own Context (containing HttpServerRequest) is correctly set per-request. But the Node.js ALS context — which frameworks like Next.js, Clerk, and OpenTelemetry rely on — is not captured at fork time or restored when the fiber's continuations execute.

3. The dangerous pattern this enables

// RPC handler — runs inside an Effect fiber
const handler = Effect.gen(function*() {
  // This calls auth() from @&#8203;clerk/nextjs/server, which reads from ALS
  const { userId } = yield* Effect.tryPromise({
    try: async () => auth(),  // ← may read WRONG user's session
    catch: () => new UnauthorizedError({ message: "Auth failed" })
  })
  return yield* repository.getUser(userId)
})

The async () => auth() thunk executes when the fiber continuation is scheduled by MixedScheduler. At that point, the ALS context belongs to an arbitrary concurrent request.

Reproduction scenario

Timeline (two concurrent requests to the same toWebHandler endpoint):

T0: Request A arrives → POST handler → webHandler(requestA)
    → Promise executor runs synchronously
    → httpRuntime.context set to A's context
    → fiber A forked, runs first ops synchronously
    → fiber A yields (e.g., at Effect.tryPromise boundary)
    → scheduler.scheduleTask(fiberA_continuation)
    → running=false → starve() called → Promise.resolve().then(drain)
       ↑ ALS context captured = Request A's context

T1: Request B arrives → POST handler → webHandler(requestB)
    → Promise executor runs synchronously
    → httpRuntime.context set to B's context
    → fiber B forked, runs first ops synchronously
    → fiber B yields
    → scheduler.scheduleTask(fiberB_continuation)
    → running=true → task queued, no new starve()

T2: Microtask fires → starveInternal() runs
    → Drains fiberA_continuation → auth() reads ALS → gets A's context ✓
    → Drains fiberB_continuation → auth() reads ALS → gets A's context ✗ ← WRONG USER

Minimal reproduction

import { AsyncLocalStorage } from "node:async_hooks"
import { Effect, Layer } from "effect"
import { RpcServer, RpcSerialization, Rpc, RpcGroup } from "@&#8203;effect/rpc"
import { HttpServer } from "@&#8203;effect/platform"
import * as S from "effect/Schema"

// Simulate a framework's ALS (like Next.js / Clerk)
const requestStore = new AsyncLocalStorage<{ userId: string }>()

class GetUser extends Rpc.make("GetUser", {
  success: S.Struct({ userId: S.String, alsUserId: S.String }),
  failure: S.Never,
  payload: {}
}) {}

const MyRpc = RpcGroup.make("MyRpc").add(GetUser)

const MyRpcLive = MyRpc.toLayer(
  RpcGroup.toHandlers(MyRpc, {
    GetUser: () =>
      Effect.gen(function*() {
        // Simulate calling an ALS-dependent API inside an Effect fiber
        const alsResult = yield* Effect.tryPromise({
          try: async () => {
            const store = requestStore.getStore()
            return store?.userId ?? "NONE"
          },
          catch: () => { throw new Error("impossible") }
        })
        return { userId: "from-effect-context", alsUserId: alsResult }
      })
  })
)

const RpcLayer = MyRpcLive.pipe(
  Layer.provideMerge(RpcSerialization.layerJson),
  Layer.provideMerge(HttpServer.layerContext)
)

const { handler } = RpcServer.toWebHandler(MyRpc, { layer: RpcLayer })

// Simulate two concurrent requests with different ALS contexts
async function main() {
  const results = await Promise.all([
    requestStore.run({ userId: "user-A" }, () => handler(makeRpcRequest("GetUser"))),
    requestStore.run({ userId: "user-B" }, () => handler(makeRpcRequest("GetUser"))),
  ])

  // Parse responses and check if alsUserId matches the expected user
  // Under the bug: both responses may show "user-A" (or one shows the other's)
  for (const res of results) {
    console.log(await res.json())
  }
}

Impact

Symptom Severity
auth() returns wrong user's session Critical — authentication bypass
cookies() / headers() from Next.js read wrong request High — data leakage
OpenTelemetry trace context crosses requests Medium — incorrect traces
Works locally, fails in production Hard to diagnose — only manifests under concurrent load

Workaround

Capture ALS-dependent values before entering the Effect runtime and pass them via Effect's own context system:

// In the route handler — OUTSIDE the Effect fiber (ALS is correct here)
export const POST = async (request: Request) => {
  const { userId } = await auth()  // ← Safe: still in Next.js ALS context

  // Inject into request headers or use the `context` parameter
  const headers = new Headers(request.headers)
  headers.set("x-clerk-auth-user-id", userId ?? "")
  const enrichedRequest = new Request(request.url, {
    method: request.method,
    headers,
    body: request.body,
    duplex: "half" as any,
  })

  return webHandler(enrichedRequest)
}

// In Effect handlers — read from HttpServerRequest headers instead of calling auth()
const getAuthenticatedUserId = Effect.gen(function*() {
  const req = yield* HttpServerRequest.HttpServerRequest
  const userId = req.headers["x-clerk-auth-user-id"]
  if (!userId) return yield* Effect.fail(new UnauthorizedError({ message: "Auth required" }))
  return userId
})

Suggested fix (for Effect maintainers)

Option A: Propagate ALS context through the scheduler

Capture the AsyncLocalStorage snapshot when a fiber continuation is scheduled, and restore it when the continuation executes:

// In MixedScheduler or the fiber runtime
import { AsyncLocalStorage } from "node:async_hooks"

scheduleTask(task: Task, priority: number) {
  // Capture current ALS context
  const snapshot = AsyncLocalStorage.snapshot()
  this.tasks.scheduleTask(() => snapshot(task), priority)
  // ...
}

AsyncLocalStorage.snapshot() (Node.js 20.5+) returns a function that, when called, restores the ALS context from the point of capture. This ensures each fiber continuation runs with its originating request's ALS context.

Trade-off: Adds one closure allocation per scheduled task. Could be opt-in via a FiberRef or scheduler option.

Option B: Capture ALS at runFork and restore per fiber step

When Runtime.runFork is called, capture the ALS snapshot and associate it with the fiber. Before each fiber step (in the fiber runtime's evaluateEffect loop), restore the snapshot.

Trade-off: More invasive but provides correct ALS propagation for the fiber's entire lifetime, including across flatMap chains and Effect.tryPromise thunks.

Option C: Document the limitation and provide a context injection API

If ALS propagation is intentionally not supported, document this prominently and provide a first-class API for toWebHandler to accept per-request context. The existing context?: Context.Context<never> parameter on the handler function partially addresses this, but it requires callers to know about the issue and manually extract values before entering Effect.

Related

POC replica of my setup

// Create web handler from Effect RPC
// sharedMemoMap ensures all RPC routes share the same connection pool
const { handler: webHandler, dispose } = RpcServer.toWebHandler(DemoRpc, {
  layer: RpcLayer,
  memoMap: sharedMemoMap,
});

/**
 * POST /api/rpc/demo
 */
export const POST = async (request: Request) => {
  return webHandler(request);
};

registerDispose(dispose);

Used util functions


/**
 * Creates a dispose registry that collects dispose callbacks and runs them
 * when `runAll` is invoked. Handles both sync and async dispose functions,
 * catching errors to prevent one failing dispose from breaking others.
 *
 * @&#8203;internal Exported for testing — use `registerDispose` in application code.
 */
export const makeDisposeRegistry = () => {
  const disposeFns: Array<() => void | Promise<void>> = []

  const runAll = () => {
    for (const fn of disposeFns) {
      try {
        const result = fn()
        if (result && typeof result.then === "function") {
          result.then(undefined, (err: unknown) => console.error("Dispose error:", err))
        }
      } catch (err) {
        console.error("Dispose error:", err)
      }
    }
  }

  const register = (dispose: () => void | Promise<void>) => {
    disposeFns.push(dispose)
  }

  return { register, runAll }
}

export const registerDispose: (dispose: () => void | Promise<void>) => void = globalValue(
  Symbol.for("@&#8203;global/RegisterDispose"),
  () => {
    const registry = makeDisposeRegistry()

    if (typeof process !== "undefined") {
      process.once("beforeExit", registry.runAll)
    }

    return registry.register
  }
)

The actual effect that was run within the RPC context that the bug was found

export const getAuthenticatedUserId: Effect.Effect<string, UnauthorizedError> =
  Effect.gen(function*() {
    const authResult = yield* Effect.tryPromise({
      try: async () => auth(),
      catch: () =>
        new UnauthorizedError({
          message: "Failed to get auth session"
        })
    })

    if (!authResult.userId) {
      return yield* Effect.fail(
        new UnauthorizedError({
          message: "Authentication required"
        })
      )
    }

    return authResult.userId
  })

Release Notes

Effect-TS/effect (effect)

v3.20.0

Compare Source

Minor Changes
Patch Changes
  • #​6107 fc82e81 Thanks @​gcanti! - Backport Types.VoidIfEmpty to 3.x

  • #​6088 82996bc Thanks @​taylorOntologize! - Schema: fix Schema.omit producing wrong result on Struct with optionalWith({ default }) and index signatures

    getIndexSignatures now handles Transformation AST nodes by delegating to ast.to, matching the existing behavior of getPropertyKeys and getPropertyKeyIndexedAccess. Previously, Schema.omit on a struct combining Schema.optionalWith (with { default }, { as: "Option" }, etc.) and Schema.Record would silently take the wrong code path, returning a Transformation with property signatures instead of a TypeLiteral with index signatures.

  • #​6086 4d97a61 Thanks @​taylorOntologize! - Schema: fix getPropertySignatures crash on Struct with optionalWith({ default }) and other Transformation-producing variants

    SchemaAST.getPropertyKeyIndexedAccess now handles Transformation AST nodes by delegating to ast.to, matching the existing behavior of getPropertyKeys. Previously, calling getPropertySignatures on a Schema.Struct containing Schema.optionalWith with { default }, { as: "Option" }, { nullable: true }, or similar options would throw "Unsupported schema (Transformation)".

  • #​6097 f6b0960 Thanks @​gcanti! - Fix TupleWithRest post-rest validation to check each tail index sequentially.

v3.19.19

Compare Source

Patch Changes

v3.19.18

Compare Source

Patch Changes

v3.19.17

Compare Source

Patch Changes

v3.19.16

Compare Source

Patch Changes
  • #​6018 e71889f Thanks @​codewithkenzo! - fix(Match): handle null/undefined in Match.tag and Match.tagStartsWith

    Added null checks to discriminator and discriminatorStartsWith predicates to prevent crashes when matching nullable union types.

    Fixes #​6017

v3.19.15

Compare Source

Patch Changes
  • #​5981 7e925ea Thanks @​bxff! - Fix type inference loss in Array.flatten for complex nested structures like unions of Effects with contravariant requirements. Uses distributive indexed access (T[number][number]) in the Flatten type utility and adds const to the flatten generic parameter.

  • #​5970 d7e75d6 Thanks @​KhraksMamtsov! - fix Config.orElseIf signature

  • #​5996 4860d1e Thanks @​parischap! - fix Equal.equals plain object comparisons in structural mode

v3.19.14

Compare Source

Patch Changes

v3.19.13

Compare Source

Patch Changes

v3.19.12

Compare Source

Patch Changes

v3.19.11

Compare Source

Patch Changes
  • #​5888 38abd67 Thanks @​gcanti! - filter non-JSON values from schema examples and defaults, closes #​5884

    Introduce JsonValue type and update JsonSchemaAnnotations to use it for
    type safety. Add validation to filter invalid values (BigInt, cyclic refs)
    from examples and defaults, preventing infinite recursion on cycles.

  • #​5885 44e0b04 Thanks @​gcanti! - feat(JSONSchema): add missing options for target JSON Schema version in make function, closes #​5883

v3.19.10

Compare Source

Patch Changes

v3.19.9

Compare Source

Patch Changes

v3.19.8

Compare Source

Patch Changes
  • #​5815 f03b8e5 Thanks @​lokhmakov! - Prevent multiple iterations over the same Iterable in Array.intersectionWith and Array.differenceWith

v3.19.7

Compare Source

Patch Changes

v3.19.6

Compare Source

Patch Changes

v3.19.5

Compare Source

Patch Changes

v3.19.4

Compare Source

Patch Changes
  • #​5752 f445b87 Thanks @​janglad! - Fix Types.DeepMutable mapping over functions

  • #​5757 d2b68ac Thanks @​tim-smart! - add experimental PartitionedSemaphore module

    A PartitionedSemaphore is a concurrency primitive that can be used to
    control concurrent access to a resource across multiple partitions identified
    by keys.

    The total number of permits is shared across all partitions, with waiting
    permits equally distributed among partitions using a round-robin strategy.

    This is useful when you want to limit the total number of concurrent accesses
    to a resource, while still allowing for fair distribution of access across
    different partitions.

    import { Effect, PartitionedSemaphore } from "effect"
    
    Effect.gen(function* () {
      const semaphore = yield* PartitionedSemaphore.make<string>({ permits: 5 })
    
      // Take the first 5 permits with key "A", then the following permits will be
      // equally distributed between all the keys using a round-robin strategy
      yield* Effect.log("A").pipe(
        Effect.delay(1000),
        semaphore.withPermits("A", 1),
        Effect.replicateEffect(15, { concurrency: "unbounded" }),
        Effect.fork
      )
      yield* Effect.log("B").pipe(
        Effect.delay(1000),
        semaphore.withPermits("B", 1),
        Effect.replicateEffect(10, { concurrency: "unbounded" }),
        Effect.fork
      )
      yield* Effect.log("C").pipe(
        Effect.delay(1000),
        semaphore.withPermits("C", 1),
        Effect.replicateEffect(10, { concurrency: "unbounded" }),
        Effect.fork
      )
    
      return yield* Effect.never
    }).pipe(Effect.runFork)

Configuration

📅 Schedule: Branch creation - "" (UTC), Automerge - At any time (no schedule defined).

🚦 Automerge: Enabled.

Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about these updates again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate bot added the effect label Mar 20, 2026
@changeset-bot
Copy link

changeset-bot bot commented Mar 20, 2026

⚠️ No Changeset found

Latest commit: 30cdb98

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@nx-cloud
Copy link
Contributor

nx-cloud bot commented Mar 20, 2026

View your CI Pipeline Execution ↗ for commit 30cdb98

Command Status Duration Result
nx affected -t build lint test typecheck e2e-ci ❌ Failed 2m 52s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-20 22:10:36 UTC

Copy link
Contributor

@nx-cloud nx-cloud bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nx Cloud has identified a possible root cause for your failed CI:

We were unable to classify this failure as a code change because the error — a Playwright web server startup timeout — does not reference any specific code, function, or import touched by the effect dependency update. As the failure is deterministic (0% flakiness rate) and isolated to this branch, we suspect an environment condition is preventing the web server from starting rather than a direct consequence of the effect 3.19.3 → 3.20.0 upgrade.

No code changes were suggested for this issue.

Trigger a rerun:

Rerun CI

Nx Cloud View detailed reasoning on Nx Cloud ↗


🎓 Learn more about Self-Healing CI on nx.dev

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

0 participants