Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

Effect.callback keeps state from finished suspensions alive until the next full GC

Chiusa
#9,497 0 commenti 0 reazioni 1 assegnatario Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@effect-bot ci sta già lavorando.

Dal 18/10/2026.

  • #9499 di @schutzelaars — integrata

Valutazione

Questa issue non è ancora stata valutata.

Descrizione

What steps can reproduce the bug?

Each time a fiber suspends in Effect.callback, callbackOptions runs:

fiber._yielded = () => {
  resumed = true
}

V8 pretenures a function literal assigned directly to a property, so this closure is allocated in the old generation. TurboFan ignores that flag, so this holds until TurboFan optimizes this code, and again after a deopt. Once the fiber resumes, the closure is dead but old, and every scavenge treats it as a root until the next full GC. Its context keeps onCancel alive. For Deferred.await, and for PubSub.take and PubSub.takeAll, which both wait in pollForItem, that reaches the completed Deferred and the value it delivered. Every SubscriptionRef.changes subscriber waits this way.

The script below sends a fresh value of about 75 KB every 50 ms for 20 s to a fiber that is waiting for it, and runs one scavenge every 4 values. It does this twice, once through a PubSub without replay and once through a new Deferred for each value. I ran it on Node 26.

npm install effect
node --expose-gc --min-semi-space-size=64 --max-semi-space-size=64 waitingFiber.mjs

The two semi-space flags make the young generation big enough that allocation does not trigger scavenges, so only the script's gc({ type: "minor" }) calls run them.

waitingFiber.mjs
// node --expose-gc --min-semi-space-size=64 --max-semi-space-size=64 waitingFiber.mjs
import v8 from "node:v8"
import { constants, performance, PerformanceObserver } from "node:perf_hooks"
import { Deferred, Effect, Exit, PubSub } from "effect"

const makeValue = () => Array.from({ length: 2000 }, (_, index) => ({ index })) // about 75 KB
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const oldGenerationBytes = () =>
  v8.getHeapSpaceStatistics()
    .filter((space) => !space.space_name.startsWith("new_"))
    .reduce((sum, space) => sum + space.space_used_size, 0)

const fullGcStarts = []
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.detail.kind === constants.NODE_PERFORMANCE_GC_MAJOR) fullGcStarts.push(entry.startTime)
  }
}).observe({ entryTypes: ["gc"] })

// Sends a fresh value every 50 ms and runs one scavenge every 4 values, so a value that is
// dropped after its turn lives through at most one scavenge and should never be promoted.
const measure = async (name, send) => {
  for (let index = 0; index < 20; index++) {
    send(makeValue())
    await sleep(50)
  }
  globalThis.gc()
  const start = performance.now()
  let promotedBytes = 0
  for (let index = 1; index <= 400; index++) {
    send(makeValue())
    await sleep(50)
    if (index % 4 === 0) {
      const before = oldGenerationBytes()
      globalThis.gc({ type: "minor", execution: "sync" })
      promotedBytes += oldGenerationBytes() - before
    }
  }
  await sleep(0)
  const fullGcs = fullGcStarts.filter((time) => time > start).length
  console.log(`${name}: ${(promotedBytes / 400 / 1024).toFixed(1)} KB promoted per value, ${fullGcs} full GCs in 20 s`)
}

// A fiber waits in PubSub.take for each value (a PubSub without replay).
const pubsub = Effect.runSync(PubSub.unbounded())
Effect.runFork(Effect.scoped(Effect.gen(function*() {
  const subscription = yield* PubSub.subscribe(pubsub)
  while (true) yield* PubSub.take(subscription)
})))
await measure("PubSub.take", (value) => Effect.runSync(PubSub.publish(pubsub, value)))

// A fiber awaits each value through a fresh Deferred.
let deferred = Deferred.makeUnsafe()
Effect.runFork(Effect.forever(Effect.suspend(() => Deferred.await(deferred))))
await measure("Deferred.await", (value) => {
  const current = deferred
  deferred = Deferred.makeUnsafe()
  Deferred.doneUnsafe(current, Exit.succeed(value))
})

process.exit(0)
What is the expected behavior?

A value the fiber has received and dropped dies in the young generation, like any other short-lived value.

What do you see instead?
PubSub.take: 74.3 KB promoted per value, 3 full GCs in 20 s
Deferred.await: 74.8 KB promoted per value, 3 full GCs in 20 s

Every value is promoted. The old generation grows by the size of each value, and V8 runs full GCs to free it.

With --allow-natives-syntax, %InYoungGeneration(fiber._yielded) returns false right after a suspension. It returns true once TurboFan has optimized the Async primitive's evaluate, which took about 1,000 suspensions in a quick check. Code that suspends rarely, like the script, stays affected, and so does code that deopts.

Additional information

yieldNowWith already stores the same kind of closure through fiber.yieldWith(...). V8 does not pretenure a literal passed as an argument, so doing the same in callbackOptions fixes this:

-fiber._yielded = () => {
+fiber.yieldWith(() => {
   resumed = true
-}
+})

With that change the script prints 0.3 and 0.2 KB promoted per value and no full GCs, and %InYoungGeneration returns true. Assigning the closure to a local const first also works before minification, but terser and Vite's minifier inline the const back into the property assignment. Neither can do that with the method call. I have the fix with a test and a changeset and will open a PR.

Other code also assigns a new closure to a property once per operation:

I have not measured those. Effect.callback runs on every suspension, so I kept the fix to it.

#9496 is a separate bug with the same effect on SubscriptionRef.


I investigated this with an AI coding agent, Claude Code. The agent read the Effect and V8 sources, wrote the repro, and ran it. I reviewed the analysis before filing.

Lingua principale
TypeScript
Stelle
16.7k
Fork
808
Merge medio
10h 7m
PR unite (30g)
808

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di Effect-TS/effect

Tutte le issue di Effect-TS/effect

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.