Skip to content

fix(core): prevent allBounded listener leak via PubSub subscription - #38939

Open
Shalin-Shah-2002 wants to merge 1 commit into
anomalyco:devfrom
Shalin-Shah-2002:fix-allbounded-leak
Open

fix(core): prevent allBounded listener leak via PubSub subscription#38939
Shalin-Shah-2002 wants to merge 1 commit into
anomalyco:devfrom
Shalin-Shah-2002:fix-allbounded-leak

Conversation

@Shalin-Shah-2002

@Shalin-Shah-2002Shalin-Shah-2002 commented Jul 26, 2026

Copy link
Copy Markdown

Issue for this PR

Closes#36677

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

The root cause of #36677 is that EventV2.allBounded relied on events.listen(), which registers the callback in a global listeners Set inside the EventV2 service. The cleanup of that registration depended on Effect.addFinalizer running inside the scope provided by Stream.unwrapChannel.unwrap. When that scope didn't close reliably (location expiry/reboot cycles, or SSE client disconnect patterns that skip channel finalization), the listener stayed in the Set permanently.

What this means for a leaked listener: every call to EventV2.notify iterates the entire listeners Set and calls each listener function. A leaked allBounded listener calls Queue.offer on every published event, allocating regardless of whether the queue is already failed. Over hours of uptime with repeated location reboots, these allocations push the JSC heap into continuous GC cycles — the 80% CPU / constant madvise behavior reported in the issue.

The fix has three parts:

1. Move allBounded out of events.listen() and into the Interface directly.

The old module-level function:

Effect.gen(function*(){constqueue=Queue.dropping(capacity)constunsubscribe=yield*events.listen(callback)// registers in global Setyield*addFinalizer(()=>unsubscribe.then(shutdown))// depends on scope closereturnStream.fromQueue(queue)})

The new Interface method inside the layer:

Effect.gen(function*(){constqueue=Queue.dropping(capacity)constsubscription=yield*PubSub.subscribe(pubsub.all)// synchronous, no raceyield*forkScoped(Stream.fromSubscription(subscription).pipe(runForEach(callback)))yield*addFinalizer(()=>Queue.shutdown(queue))returnStream.fromQueue(queue)})

The key difference: PubSub.subscribe(pubsub.all) registers the subscriber directly on the PubSub, not in a separate global Set. The subscription is managed by Scope — when the scope closes (stream ends or is interrupted), the subscriber is removed atomically as part of PubSub's internal subscriber tracking. There is no separate cleanup step that can be skipped.

PubSub.subscribe is also synchronous — it returns the subscriber immediately after registering it. This means there is no race between subscribing and publishing events, so the existing test (which creates bounded streams then immediately publishes) continues to pass.

2. Added missing Queue.shutdown in the opencode SSE handler finalizer.

At packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts:32, the finalizer was calling unsubscribe but never Queue.shutdown(queue). This meant the unbounded queue survived the SSE disconnect, holding references to buffered events.

3. Collected unsubscription effects in share-next watch helper.

At packages/opencode/src/share/share-next.ts, the watch function called events.listen() but never stored or ran the returned Unsubscribe effect. The listeners accumulated for the process lifetime. The fix stores each unsubscribe and registers them as a scope finalizer.

How did you verify your code works?

  • All 44 event tests pass, specifically including ends only an overflowing bounded subscriber without blocking other listeners which tests the exact bounded-stream pattern used in production.
  • Full core test suite: 1080 tests pass, 0 fail across 142 files.
  • TypeScript typecheck passes in packages/core and packages/server.
  • The monorepo-wide bun turbo typecheck passes across all 36 packages.

Screenshots / recordings

Not applicable — logic change only.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Changes allBounded to subscribe via PubSub.subscribe(pubsub.all) instead
of events.listen(), avoiding accumulation in the global listeners Set
when scope cleanup is delayed or skipped on location expiry/reboot cycles.
Also fixes related event cleanup issues:
- Add missing Queue.shutdown to opencode SSE handler finalizer
- Collect and finalize share-next event listener unsubscribes
Fixesanomalyco#36677
@github-actionsgithub-actionsBot added needs:compliance This means the issue will auto-close after 2 hours. and removed needs:compliance This means the issue will auto-close after 2 hours. labels Jul 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@Shalin-Shah-2002

Copy link
Copy Markdown
Author

This PR is ready for review. All tests pass (1080/1080), typecheck is clean across all 36 packages, and the fix directly addresses the root cause of the allocation loop described in #36677.

renekris added a commit to renekris/opencode-lowmem that referenced this pull request Aug 22, 2026
…ray semantics
Deviations from the ported upstream hunks, per review:
- anomalyco#43881: the empty-stream guard now fails only when the attempt produced
no text/reasoning delta and no tool-call event. Providers may stream real
content but omit the usage block and finish reason; retrying those would
duplicate already-persisted output (up to 6 attempts). Empty-content
deltas still count as empty, so the original clean-EOF retry is intact.
- anomalyco#38939: listeners are an Array again. The Set container silently
deduplicated identical callback registrations and removed every entry on
the first unsubscribe; duplicates must deliver independently.
- adds the missing multi-byte UTF-8 split regression for anomalyco#43607's
streaming TextDecoder
renekris added a commit to renekris/opencode-lowmem that referenced this pull request Aug 22, 2026
…port of upstream anomalyco#38939)
allBounded now owns a bounded dropping queue instead of leasing the
unbounded global listener stream. Deviation from the upstream patch:
listeners stay an Array (a Set would silently deduplicate identical
callback registrations and remove every entry on first unsubscribe);
covered by regression tests.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: long-lived V2 server enters persistent allocation loop

1 participant

@Shalin-Shah-2002