Replay-enabled PubSub keeps every published value alive until the next full GC

クローズ
#9,496 コメント 0 件 リアクション 0 件 担当者 1 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@effect-bot がすでに取り組んでいます。

2026年10月18日 から。

  • #9498 @schutzelaars による — マージ済み

評価

この issue はまだ評価されていません。

説明

What steps can reproduce the bug?

Every SubscriptionRef is a PubSub.unbounded({ replay: 1 }). When the replay buffer is full, ReplayBuffer.offer drops its head with this.head = this.head.next!, but the dropped node keeps its value and its next.

If a full GC promoted that node while it was the head, it is now dead but in the old generation, and every scavenge treats its pointers into the young generation as roots until the next full GC. Through next it keeps every later node and its value alive. Those get promoted, and the last promoted node does the same to the nodes after it. So until the next full GC, every published value ends up in the old generation.

The script below sets a fresh value of about 75 KB every 50 ms for 20 s and runs one scavenge every 4 updates, so a replaced value lives through at most one scavenge. It compares a Ref with a SubscriptionRef that has no subscribers. I ran it on Node 26.

npm install effect
node --expose-gc --min-semi-space-size=64 --max-semi-space-size=64 subscriptionRef.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.

subscriptionRef.mjs
// node --expose-gc --min-semi-space-size=64 --max-semi-space-size=64 subscriptionRef.mjs
import v8 from "node:v8"
import { constants, performance, PerformanceObserver } from "node:perf_hooks"
import { Effect, Ref, SubscriptionRef } 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"] })

// Sets a fresh value every 50 ms and runs one scavenge every 4 updates, so a value that is
// replaced after one update lives through at most one scavenge and should never be promoted.
const measure = async (name, set) => {
  for (let index = 0; index < 20; index++) {
    set(makeValue())
    await sleep(50)
  }
  globalThis.gc()
  const start = performance.now()
  let promotedBytes = 0
  for (let index = 1; index <= 400; index++) {
    set(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 update, ${fullGcs} full GCs in 20 s`)
}

const ref = Effect.runSync(Ref.make(makeValue()))
await measure("Ref", (value) => Effect.runSync(Ref.set(ref, value)))

const subscriptionRef = Effect.runSync(SubscriptionRef.make(makeValue()))
await measure("SubscriptionRef", (value) => Effect.runSync(SubscriptionRef.set(subscriptionRef, value)))
What is the expected behavior?

A SubscriptionRef promotes no more than a Ref. A value that has been replaced dies in the young generation.

What do you see instead?
Ref: 0.0 KB promoted per update, 0 full GCs in 20 s
SubscriptionRef: 74.5 KB promoted per update, 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 that a Ref holding the same values never needs. An app that keeps large state in a SubscriptionRef and replaces it often pays for this with full-GC pauses.

Additional information

The other buffers in PubSub.ts already clear what they drop:

ReplayBuffer is the only one that does not. Nothing reads a node after offer drops it. Since #6805, which fixed #6804, a different leak in this code, ReplayWindowImpl copies the values it needs. So offer can clear the node it drops:

 if (this.size === this.capacity) {
-  this.head = this.head.next!
+  const dropped = this.head
+  this.head = dropped.next!
+  dropped.next = null
+  dropped.value = AbsentValue
 }

With that change the script prints SubscriptionRef: 0.0 KB promoted per update, 0 full GCs in 20 s. I have the fix with a test and a changeset and will open a PR.

A SubscriptionRef.changes subscriber that is waiting for the next value keeps values alive in a second, separate way, reported in #9497.


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.

主要言語
TypeScript
スター
16.7k
フォーク
808
平均マージ
11時間 31分
マージ済み PR(30日)
834

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

Effect-TS/effect のほかの issue

Effect-TS/effect の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。