Purge per-kernel subscription residue at kernel close#1189
Merged
Conversation
…canary The cycle protocol and every context/render-tree weakref assertion stay green while per-kernel listener entries accumulate on module-level stores: Computed's auto-subscribe registrations are not removed at kernel close, and each dead entry pins its closure and captures. Found in a large production app leaking ~5 GB/instance/day; invisible at lab scale because the captured closures were KBs, hidden in the allocator tail. Adds the counters rule (per-store listener totals must return to baseline every cycle), an amplification canary app (1 MB payload captured per render in a component-scoped computed; measured: constant ~22 MB/cycle on 1.60.1 with 'kernels after close: 0' green throughout), the gc.freeze / getattr-sweep / objgraph-scale diagnosis traps, and fix guidance including the session-scope and close-ordering pitfalls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Subscriptions made inside a kernel without a paired unsubscribe — most importantly Computed's AutoSubscribeContextManager, which subscribes outside any effect — stayed in a store's listeners dicts forever under the dead kernel's scope id. Each entry pins its listener closure and everything it captures; on process-lifetime (module-level) stores that is a leak per kernel, and a component-body Computed leaks its captures once per render. Measured in a large production app at ~5 GB/instance/day, with allocator fragmentation amplifying the scattered survivors ~7x at the OS level. Changes: - ValueBase registers, once per (store, kernel-scope), an on_close purge of that scope's listener entries. Only scopes owned by the closing kernel are purged: session scopes (persist=True) are shared by all of a session's kernels and must survive kernel close. - subscribe/subscribe_change cleanups use discard-and-prune semantics: the purge runs in on_close, before rc.close() runs effect cleanups, and a raising cleanup would abort the render context teardown. - fire() no longer materializes defaultdict entries, which permanently grew the listener dicts by one empty set per kernel that ever fired. - Singleton/Computed on_kernel_start registrations are unregistered when the creating kernel closes (Computed previously discarded the cleanup entirely, pinning itself and its captures in the process-global callback list per instance). Module-level instances are unaffected: registrations made in the app's short-lived dummy context are deliberately not scoped to it, which also keeps hot reload's storage resets working. - lifecycle.on_kernel_start cleanup is idempotent (unmount cleanup and kernel-close unregistration may both run). Measured with docs/memory-measurement/leak_canary_app.py (10 pages x 10 cycles): idle footprint 122->338 MB (constant ~22 MB/cycle line) before, flat ~130-142 MB after — with 'kernels after close: 0' green in both runs, which is why every existing cleanup assertion missed this class of leak (see the new case study 4 in docs/memory-usage-inspection.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With SOLARA_STORAGE_MUTATION_DETECTION=1 (the solara 2.0 CI mode) Reactive wraps its kernel store in a MutateDetectorStore, so the subscription lands one level deeper than store._storage. Walk the _storage chain instead of probing a fixed depth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… warnings Replace the ValueBase-level scope purge with the structurally simpler owner cleanup: the Computed's per-kernel AutoSubscribeContextManager already holds its unsubscribe closures, so it registers kernel_context.on_close( unsubscribe_all) when created inside a kernel. Nothing external mutates the listeners dicts anymore, session-scoped (persist) stores need no special casing (the owner removes exactly the entries it added), and unsubscribing prunes empty scope entries so it leaves zero residue. Two new warnings, each once per call site: - a Computed created during a render (new Computed + registrations + subscriptions per render; the production leak's biggest term) - a subscription made inside a kernel, outside any render, whose cleanup was never called by the time the kernel closed. Render-time subscriptions are component-managed by contract (use_effect cleanups); solara-internal owners mark themselves via _managed_subscription(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Kernel-scoped subscriptions on process-lifetime stores (module-level
Reactive/Computed) were never removed at kernel close. Every kernel left(listener, Context)tuples — and everything the listener closures capture — behind, forever. A@solara.lab.computedinside a component body leaks its captures once per render. Found in a large production solara application leaking ~5 GB per instance per business day while every existing cleanup check stayed green.Why nothing caught it
Effect-managed subscriptions unsubscribe at close (
rc.close()runs effect cleanups), butComputed'sAutoSubscribeContextManagersubscribes outside any effect — and even module-level Computeds subscribe per kernel, since their value is kernel-scoped. The leaked entries do not pin the kernel context or render tree, so weakref assertions andkernels after close: 0counters stay green. At lab scale the closures are KBs, hidden in the allocator tail; in production they captured MB-scale page data and pymalloc/glibc fragmentation amplified the scattered survivors ~7× at the OS level.Full write-up: case study 4 in
docs/memory-usage-inspection.md(added here), including the new detection rules and the amplification canary.Design: the owner cleans up its own subscriptions
The subscriptions that leak all have an owner — the Computed's per-kernel
AutoSubscribeContextManager, which already holds its unsubscribe closures. So:AutoSubscribeContextManagercreated inside a kernel registerskernel_context.on_close(self.unsubscribe_all). Kernel dies → its subscriptions are removed through the normal cleanup path. No external code mutates the listeners dicts, and session-scoped (persist=True) stores need no special casing: the owner removes exactly the entries it added.fire()no longer materializes defaultdict entries (previously: one permanent empty set per kernel that ever fired, per store).Singleton/Computedon_kernel_startregistrations are unregistered when the creating kernel closes (Computedpreviously discarded the cleanup entirely). Registrations from the app's short-liveddummycontext are deliberately not scoped to it — module-level instances must keep their hot-reload resets.lifecycle.on_kernel_startcleanup is idempotent (unmount cleanup and kernel-close unregistration may both run).Two new warnings (once per call site)
Computedcreated during a render — creates a new Computed, plus registrations and subscriptions, on every render; this was the biggest term of the production leak.use_effectcleanups); solara-internal owners mark themselves via_managed_subscription().Measurements
docs/memory-measurement/leak_canary_app.py(added): a component-body computed capturing a 1 MB payload per render, reading a module-level reactive. 10 pages × 10 cycles, macOS physical footprint:Both runs report
kernels after close: 0on every cycle.The production application (docker rig, real data, playbook cycle protocol): 16.1 MB/cycle → 2.4 MB/cycle with this fix stacked with
MALLOC_ARENA_MAX=2(the residual is bounded thread-pool ramp).Tests
test_kernel_close_purges_subscription_residue: fails on master, passes here.test_computed_created_during_render_warns,test_unreleased_subscription_warns_at_kernel_close: the new warnings.SOLARA_STORAGE_MUTATION_DETECTION=1.🤖 Generated with Claude Code