Skip to content

fix(RcMap): invalidate should close scope regardless of refCount - #1799

Closed
kitlangton wants to merge 2 commits into
Effect-TS:mainfrom
kitlangton:kit/rcmap-invalidate-fix
Closed

fix(RcMap): invalidate should close scope regardless of refCount#1799
kitlangton wants to merge 2 commits into
Effect-TS:mainfrom
kitlangton:kit/rcmap-invalidate-fix

Conversation

@kitlangton

@kitlangton kitlangton commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Howdy all! I ran into some unexpected semantics when using LayerMap in the opencode repo. As it says at the bottom, I'm not sure if this is intended or not, but here's a reproducer and potential fix if it isn't indeed unintended.

Human Approved Robot Summary 🤖🤝👵

Problem

RcMap.invalidate does not close the scope or interrupt consumers when refCount > 0. It removes the entry from the map and silently returns:

MutableHashMap.remove(self.state.map, key)
if (entry.refCount > 0) return              // ← silently abandons
if (entry.fiber) yield* Fiber.interrupt(entry.fiber)
yield* Scope.close(entry.scope, Exit.void)  // never reached

This is inconsistent with ScopedCache.invalidate which always closes the scope:

MutableHashMap.remove(self.state.map, key)
return Scope.close(oentry.value.scope, effect.exitVoid)  // no refCount check

Every other Effect lifecycle abstraction (FiberMap, FiberSet, ScopedCache) interrupts and tears down on invalidation/close. RcMap is the only one that silently abandons active consumers without interrupting them.

Impact

This makes LayerMap (which wraps RcMap) unable to cleanly tear down services with long-running consumers. When a layer has a PubSub that streams subscribe to, invalidate can't trigger the layer's finalizers to shut down the PubSub, so streams never end and ensuring callbacks never fire.

Fix

Remove the if (entry.refCount > 0) return line. invalidate now always closes the scope and runs finalizers, matching ScopedCache semantics.

Test

Added: "invalidate with active consumer closes scope" — acquires a resource, holds the scope open, invalidates, and asserts the finalizer runs. This test fails before the fix and passes after.

All 8 existing RcMap tests continue to pass (they only test invalidate with refCount=0).

Question for maintainers

Is the silent abandon behavior (bail on refCount > 0) intentional? If so, should there be a separate forceInvalidate API? The current behavior means "invalidate" doesn't actually invalidate when consumers are active — it just removes the entry from the map and hopes consumers eventually release on their own.

RcMap.invalidate bails when refCount > 0, leaving the entry scope
open and finalizers unrun. This is inconsistent with ScopedCache.invalidate
which always closes the scope.

Remove the refCount > 0 early return so invalidate always closes the
scope and runs finalizers, matching ScopedCache semantics.

Add test: invalidate with active consumer closes scope and runs finalizer.
@changeset-bot

changeset-bot Bot commented Mar 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2eddcbf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 26 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@kitlangton
kitlangton marked this pull request as ready for review March 19, 2026 14:57
kitlangton added a commit to anomalyco/opencode that referenced this pull request Mar 19, 2026
…ffect tests

Legacy subscribeAll delivers InstanceDisposed via GlobalBus because
the fiber starts asynchronously and may not be running when disposal
happens. This bridge can be removed once upstream PubSub.shutdown
properly wakes suspended subscribers.

Add forceInvalidate in Instances that closes the RcMap entry scope
regardless of refCount. Standard RcMap.invalidate bails when
refCount > 0 — an upstream issue (Effect-TS/effect-smol#1799).

Add PubSub shutdown finalizer to Bus layer so layer teardown
properly cleans up PubSubs.

Add Effect-native tests proving forkScoped + scope closure works
correctly: ensuring fires when the scope closes, streams receive
published events.

Remove stale GlobalBus disposal test (instance.ts responsibility).
@tim-smart

tim-smart commented Mar 19, 2026

Copy link
Copy Markdown
Collaborator

This is the intended behaviour. RcMap should only closed the resource when the ref count reaches 0.

You can use ScopedCache if you don't need ref counting.

@kitlangton kitlangton closed this Mar 19, 2026
Sign up for free to 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.

2 participants