Skip to content

fix(effect): handle synchronous re-entrancy in Effect.cached* - #8977

Merged
tim-smart merged 6 commits into
Effect-TS:mainfrom
kitlangton:fix-cached-reentrancy
Oct 10, 2026
Merged

tim-smart merged 6 commits into
Effect-TS:mainfrom
kitlangton:fix-cached-reentrancy

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Found while upgrading a large app to Effect 4.0.1.

In makeCachedUnsafe (shared by Effect.cached, Effect.cachedWithTTL, and Effect.cachedInvalidateWithTTL), a cache miss publishes current = run with run.fiber = undefined and then calls run.fiber = forkUnsafe(fiber, ..., true, true). Because immediate is true, forkUnsafe evaluates the cached effect synchronously before returning the new fiber.

If that synchronous evaluation wakes another fiber that immediately evaluates the same cached effect before forkUnsafe returns (for example, completing a Deferred whose waiter runs a scope finalizer that calls the cached effect), the second fiber sees current !== undefined and calls join(fiber, current) while run.fiber is still undefined:

  1. fiberJoin(run.fiber!) throws TypeError: Cannot read properties of undefined (reading '_exit').
  2. When the second fiber unwinds, its onExitUnsafe handler decrements run.awaiters from 1 to 0 (because the initiating caller only increments run.awaiters after forkUnsafe returns), clears current = undefined, and calls fiberInterrupt(undefined).
  3. fiberInterruptAs is dual and checks hasProperty(args[0], FiberTypeId), so passing undefined returns a curried function instead of an Effect, which fails the run loop with:
Unknown Error: Fiber.runLoop: Not a valid effect: (self) => body(self, ...arguments)

Minimal reproduction:

const gate = yield* Deferred.make<void>()
const cached = yield* Effect.cached(
  Effect.gen(function*() {
    yield* Deferred.succeed(gate, void 0)
    return 42
  })
)

const waiter = yield* Deferred.await(gate).pipe(
  Effect.andThen(cached),
  Effect.forkChild({ startImmediately: true })
)

yield* cached
yield* Fiber.join(waiter)

Fix

  • Construct run.fiber = new FiberImpl(fiber.context) on CachedRun before publishing current = run and calling run.fiber.evaluate(...), so run.fiber is always defined when a re-entrant caller joins or interrupts it.
  • Initialize run.awaiters to 1 for the initiating caller and increment current.awaiters when subsequent callers join, so a re-entrant waiter that joins and is interrupted during synchronous startup cannot drop run.awaiters to 0 and abandon the run while evaluate is still on the stack.
  • If the cached computation re-enters the cached effect on its own fiber (fiber === current.fiber), return fiberJoin(current.fiber) without incrementing current.awaiters or registering an abandonment finalizer. A direct self-call suspends on self-join instead of crashing, and interrupting all external callers still drops run.awaiters to 0 and interrupts run.fiber.

Alternatives

  1. Schedule the cached fiber with immediate = false (forkUnsafe(fiber, ..., false, true)). This would assign run.fiber and register the first caller before the cached body runs a single step, eliminating synchronous re-entrancy during startup. However, it gives up synchronous evaluation on cache misses: run.fiber._exit would never be populated synchronously, every first call would suspend until the scheduler flushes, and callers relying on synchronous startup effects (such as Effect.forkChild(cached, { startImmediately: true })) would observe different ordering.
  2. Back CachedRun with a Deferred or Latch (similar to ScopedCache). Waiting on a Deferred avoids reading run.fiber in join, but it allocates an extra coordination object per cache miss and still requires run.fiber to be defined if a re-entrant waiter is interrupted during synchronous startup.
  3. Broader follow-up across Cache, RcRef, and ResourceMap. RcRef and ResourceMap also publish an in-flight state with fiber: undefined as any before Effect.runForkWith evaluates the acquire/lookup effect synchronously, and Cache.get runs new EntryImpl (which calls forkUnsafe(..., true, true)) before inserting the entry into self.map. Keeping this PR scoped to makeCachedUnsafe keeps the fix minimal, and the same pre-allocated fiber pattern can be applied to those modules if desired.

Tests

pnpm test --run packages/effect/test/Effect.test.ts -t "cached"
pnpm test --run packages/effect/test/Effect.test.ts
pnpm test --run packages/effect/test/Cache.test.ts
pnpm lint
pnpm check

Closes EFF-1910

@changeset-bot

changeset-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 41fde49

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 32 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

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

@effect-janitor effect-janitor Bot added bug Something isn't working 4.0 labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 38.84 KB 38.84 KB 0.00 KB (0.00%)
basic.ts 7.07 KB 7.07 KB 0.00 KB (0.00%)
batching.ts 10.15 KB 10.15 KB 0.00 KB (0.00%)
brand.ts 6.73 KB 6.73 KB 0.00 KB (0.00%)
cache.ts 10.92 KB 10.92 KB 0.00 KB (0.00%)
config.ts 22.32 KB 22.32 KB 0.00 KB (0.00%)
differ.ts 21.35 KB 21.35 KB 0.00 KB (0.00%)
http-client.ts 22.73 KB 22.71 KB +0.01 KB (+0.06%)
http-router.ts 34.17 KB 34.15 KB +0.02 KB (+0.07%)
logger.ts 11.25 KB 11.25 KB 0.00 KB (0.00%)
metric.ts 9.04 KB 9.04 KB 0.00 KB (0.00%)
optic.ts 6.96 KB 6.96 KB 0.00 KB (0.00%)
pubsub.ts 15.24 KB 15.24 KB 0.00 KB (0.00%)
queue.ts 12.16 KB 12.16 KB 0.00 KB (0.00%)
schedule.ts 11.27 KB 11.27 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 13.78 KB 13.78 KB 0.00 KB (0.00%)
schema-binary.ts 40.03 KB 40.03 KB 0.00 KB (0.00%)
schema-class.ts 21.11 KB 21.11 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 32.16 KB 32.16 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 27.33 KB 27.33 KB 0.00 KB (0.00%)
schema-string-transformation.ts 14.54 KB 14.54 KB 0.00 KB (0.00%)
schema-string.ts 12.04 KB 12.04 KB 0.00 KB (0.00%)
schema-template-literal.ts 16.11 KB 16.11 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 38.37 KB 38.37 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 25.32 KB 25.32 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 20.28 KB 20.28 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 20.49 KB 20.49 KB 0.00 KB (0.00%)
schema-toFormatter.ts 20.59 KB 20.59 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 25.06 KB 25.06 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 20.57 KB 20.57 KB 0.00 KB (0.00%)
schema.ts 20.33 KB 20.33 KB 0.00 KB (0.00%)
stm.ts 13.29 KB 13.29 KB 0.00 KB (0.00%)
stream.ts 10.00 KB 10.00 KB 0.00 KB (0.00%)

@tim-smart
tim-smart marked this pull request as ready for review October 10, 2026 07:15
@tim-smart
tim-smart enabled auto-merge (squash) October 10, 2026 07:15
@tim-smart
tim-smart merged commit b4e5af8 into Effect-TS:main Oct 10, 2026
7 of 10 checks passed
brandhaug added a commit to brandhaug/b2b-saas-starter that referenced this pull request Oct 15, 2026
## pnpm-workspace.yaml (default)

## Dependency Updates

| Package | From | To | Type |
| --- | --- | --- | --- |
| `@inlang/plugin-message-format` | 4.5.0 | 4.5.2 | patch |
| `@tanstack/react-query` | 5.104.1 | 5.104.2 | patch |
| `effect` | 4.0.2 | 4.0.3 | patch |

## Release Notes

<details>
<summary><b>@<!---->tanstack/react-query</b> (5.104.1 →
5.104.2)</summary>

### Patch Changes

- Updated dependencies
[[`03e8c39`](TanStack/query@03e8c39)]:
  - @<!---->tanstack/[email protected]

</details>

<details>
<summary><b>effect</b> (4.0.2 → 4.0.3)</summary>

### Patch Changes

- [#8977](Effect-TS/effect#8977)
[`b4e5af8`](Effect-TS/effect@b4e5af8)
Thanks @<!---->kitlangton! - Prevent crashes and premature interruption
when `Effect.cached`, `Effect.cachedWithTTL`, and
`Effect.cachedInvalidateWithTTL` are synchronously re-entered.

- [#8982](Effect-TS/effect#8982)
[`b8d6e7a`](Effect-TS/effect@b8d6e7a)
Thanks @<!---->Yi-111-a! - Export `CliOutput.displayWidth` to measure
terminal-cell widths using the same logic as built-in CLI help and
tables.

- [#8933](Effect-TS/effect#8933)
[`89d5c88`](Effect-TS/effect@89d5c88)
Thanks @<!---->tim-smart! - Respect `NO_COLOR` in CLI prompts and the
command wizard, suppressing color and style escapes while preserving
cursor and redraw controls.

- [#8941](Effect-TS/effect#8941)
[`66257d2`](Effect-TS/effect@66257d2)
Thanks @<!---->Tanner-Scadden! - Hand cluster shards off one at a time
when `Sharding` shuts down, after their entities and singletons have
stopped, so live runners can take them over sooner. The handoff is best
effort: its wait is bounded, and if it times out, `releaseAll` attempts
to release the remaining locks. A runner that is shutting down no longer
acquires new shards, and releasing a shard no longer waits forever on an
entity whose id is also active on another shard.

- [#8940](Effect-TS/effect#8940)
[`cd2ab19`](Effect-TS/effect@cd2ab19)
Thanks @<!---->Tanner-Scadden! - Fix cluster shard handoffs. Interrupts
and stream acknowledgements now reach requests still draining on the
previous owner, and `RpcServer` accepts acknowledgements for active
streams after a client's EOF. Volatile requests sent duri

…[full
notes](https://github.com/Effect-TS/effect/releases/tag/effect%404.0.3)

</details>

---
*This PR was auto-generated by
[catalog-update-action](https://github.com/brandhaug/catalog-update-action).*

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

This branch had an error being deployed

1 failed deployment
fork — 41fde498 Deployed Oct 10, 2026 by tim-smart via approval-gate #18800
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants