What version of Effect is running?
effect@4.0.1
What steps can reproduce the bug?
he first lookup signals a Deferred and then hangs. The parent awaits that
signal and immediately interrupts the fiber calling Cache.get. The failure
TTL is zero, so an interrupted lookup should leave the cache. The second run
adds one Effect.yieldNow before the interrupt as a control.
import { Cache, Deferred, Duration, Effect, Exit, Fiber, Option } from "effect"
const run = (label: string, beforeInterrupt: Effect.Effect<void>) =>
Effect.gen(function*() {
const started = yield* Deferred.make<void>()
let calls = 0
const cache = yield* Cache.makeWith(
(_: string) =>
Effect.suspend(() => {
calls += 1
if (calls > 1) return Effect.succeed("ok")
// first lookup: signal that it started, then hang
return Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never))
}),
{ capacity: 4, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero) }
)
const first = yield* Cache.get(cache, "k").pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* beforeInterrupt
yield* Fiber.interrupt(first)
const size = yield* Cache.size(cache)
const next = yield* Cache.get(cache, "k").pipe(Effect.timeoutOption("1 second"))
console.log(`${label}: size after interrupt = ${size}, next get = ${Option.getOrElse(next, () => "timed out")}`)
})
await Effect.runPromise(Effect.gen(function*() {
yield* run("interrupt right after the lookup starts", Effect.void)
yield* run("interrupt after one Effect.yieldNow", Effect.yieldNow)
}))
Run with bun repro.ts (or npx tsx repro.ts) after installing effect@4.0.1.
What is the expected behavior?
Interrupting the only caller abandons the lookup: it is interrupted, the entry is removed (size 0), and the next get starts a fresh lookup and returns ok, as in the control run.
What do you see instead?
interrupt right after the lookup starts: size after interrupt = 1, next get = timed out
interrupt after one Effect.yieldNow: size after interrupt = 0, next get = ok
The first lookup is never interrupted, its entry stays in the map, and every later get for the key joins it and waits forever.
Additional information
What version of Effect is running?
effect@4.0.1
What steps can reproduce the bug?
he first lookup signals a
Deferredand then hangs. The parent awaits thatsignal and immediately interrupts the fiber calling
Cache.get. The failureTTL is zero, so an interrupted lookup should leave the cache. The second run
adds one
Effect.yieldNowbefore the interrupt as a control.Run with
bun repro.ts(ornpx tsx repro.ts) after installingeffect@4.0.1.What is the expected behavior?
Interrupting the only caller abandons the lookup: it is interrupted, the entry is removed (size 0), and the next
getstarts a fresh lookup and returnsok, as in the control run.What do you see instead?
The first lookup is never interrupted, its entry stays in the map, and every later
getfor the key joins it and waits forever.Additional information
packages/effect/src/Cache.ts:420-463(
get) forks the lookup immediately, then returnsentry.await().Cache.ts:480-492(
EntryImpl.await) incrementsawaiterswhen it is called, but thedecrement and the interrupt of the abandoned lookup live in the returned
onExiteffect. If the lookup's first steps wake a fiber that interruptsthe caller, the interrupt is delivered before that effect starts: the
awaiter is counted and never released.
release on the caller fiber (
onExitUnsafe) before returning, with a testthat times out on
main. It was closed unmerged by its author with nocomment, after fix(effect): treat interruption as abandonment in Effect.cached* and Cache #8719 merged.
Effect.cached*andCache,shipped in 4.0.1) made abandoned entries leave the map once the last awaiter
leaves; it does not cover an awaiter that never gets to leave. The repro
above fails on 4.0.1 and on
main(last change toCache.ts: bc44526,fix(effect): treat interruption as abandonment in Effect.cached* and Cache #8719).