Replay-enabled PubSub keeps every published value alive until the next full GC
メンテナーはふだん 1 日以内に返信
評価
この 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:
- the bounded implementations reset a slot to
AbsentValue UnboundedPubSubclears the value of each node once every subscriber has taken itReplayWindowImplclears what it hands out
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 を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
Effect-TS/effect のほかの issue
-
Xml: `positions` decode option that puts `line` and `column` on `StartElement` events対応中かも @effect-bot が今日担当しました。 オープン
Effect-TS/effect#10400 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
effect/ai: support the MCP Apps extension (`io.modelcontextprotocol/ui`)対応中かも @effect-bot が今日担当しました。 オープン
Effect-TS/effect#10399 · コメント 2 件 · リアクション 1 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
Flat RPC client and AtomRpc query/mutation: type-check cost grows with RPCs in the group × tags called対応中かも @effect-bot が今日担当しました。 オープン
Effect-TS/effect#10396 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
Support `format: "password"` as `Redacted` in JSON Schema import/export and the OpenAPI generator対応中かも @effect-bot が今日担当しました。 オープンenhancement
Effect-TS/effect#10394 · コメント 2 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
Versioned schemas with version negotiation in HttpApi, Rpc and cluster対応中かも @effect-bot が今日担当しました。 オープン
Effect-TS/effect#10383 · コメント 2 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
Effect-TS/effect の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
メンテナーはふだん 2 日以内に返信
-
reproduced
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
anomalyco/opencode#62052 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
strapi/design-system#2084 ·
メンテナーはふだん 3 日以内に返信
-
check:passed streams:remove
難易度 1/5 1時間未満 初心者へのやさしさ 67/100
メンテナーはふだん 1 日以内に返信
-
OAuth documentation for GET /api/v2/users/me/ appears inconsistent with required global read scopeオープン
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
メンテナーはふだん 1 日以内に返信