Separate TODO and singleton cleanup paths during incremental invalidation#736
Separate TODO and singleton cleanup paths during incremental invalidation#736
Conversation
4d03161 to
0d94164
Compare
0d94164 to
4810117
Compare
ba4d491 to
def42e4
Compare
|
@vinistock I decided to merge a follow up PR to this one, which is a much bigger change. Can you give it another look? Thanks |
7f57671 to
99c8b04
Compare
|
Changing this back to draft because I think it hits infinite loops in some cases. |
a77765a to
bdf6d42
Compare
2e25ad5 to
93b64c1
Compare
e2201cb to
79ccbcf
Compare
79ccbcf to
f67a532
Compare
| /// members (e.g. `@x`, `def self.bar`) and by inbound constant references | ||
| /// (e.g. `Foo.new`). | ||
| #[must_use] | ||
| pub fn has_no_backing_definitions(&self) -> bool { |
There was a problem hiding this comment.
This method probably needs to be renamed. It doesn't check for definitions exclusively.
Maybe this can be generalized as has_no_dependents? Or something along those lines.
|
|
||
| /// Clears `singleton_class_id` only if it matches `expected`. | ||
| /// Prevents accidentally clearing a pointer to a re-created singleton. | ||
| pub fn clear_singleton_class_id(&mut self, expected: DeclarationId) { |
There was a problem hiding this comment.
This method is a bit weird. What do you think about having this method only set self.singleton_class_id = None and perform the check outside?
|
|
||
| /// Returns true if this namespace has no definitions and no members. | ||
| #[must_use] | ||
| pub fn is_empty(&self) -> bool { |
There was a problem hiding this comment.
The name is_empty here might not be descriptive enough. For example, for a singleton class, you could have no members, no definitions and still have references. E.g.:
class Foo; end
Foo.methodsThat would classify as "empty".
| /// could still add `module Math` and promote it. `cleanup_stale_todos` | ||
| /// sweeps this list after resolution and removes entries that are | ||
| /// still empty. | ||
| tracked_todos: IdentityHashSet<DeclarationId>, |
There was a problem hiding this comment.
We're starting the take on a crazy amount of complexity to account for todo declarations. I keep asking myself if this is worth it.
The only use case we have is Tapioca wanting to include RBIs even for things that indeed don't exist in the codebase. For example:
# some Minitest extension gem
class Minitest::MyExtension
endIf Minitest exists, we will correctly provide the right declarations. If not, then it's because the workspace doesn't use Minitest (which would make it disappear from RBIs, but not result in any type checking failure since it is not used).
I wonder if there's another way to account for the Tapioca use case of wanting to include a one to one representation of the codebase ignoring undefined constants without having to deal with all of this todo complexity.
| /// 2. Post-resolution cleanup — some TODOs stay empty forever. | ||
| /// Example: `class << Math; def log2(x); x; end; end` with no | ||
| /// `module Math` anywhere. The resolver creates a TODO `Math` as | ||
| /// the singleton's parent, but the singleton attaches via | ||
| /// `set_singleton_class_id`, not `add_member`. So TODO `Math` | ||
| /// never gains a member; nothing is ever removed, so (1) never | ||
| /// fires. We can't decide this at creation time — a later file | ||
| /// could still add `module Math` and promote it. `cleanup_stale_todos` | ||
| /// sweeps this list after resolution and removes entries that are | ||
| /// still empty. |
There was a problem hiding this comment.
Reading this, do we need to create a todo in this case? I think we can limit todo creation to the module and class keywords, but excluding singleton class blocks (after all, the target is a reference).
f67a532 to
360c9a6
Compare
360c9a6 to
d55bfaa
Compare
After incremental invalidation, declarations can lose all their definitions. Previously these were removed immediately, destroying accumulated state (singleton links, member lists) the resolver needs. This defers TODO cleanup to after resolution via `cleanup_stale_todos()`. TODOs created speculatively by `create_todo_for_parent` are marked via `defer_todo_cleanup` and evaluated post-resolution — promoted TODOs and those with members are kept, empty stubs are removed. Singleton classes use references and members as "still in use" signals in `has_no_backing_definitions`, preventing premature removal when callers (Foo.new) or class-level state (@x = 1) still exist. Other empty declarations (from file deletion or member removal) cascade immediately via the invalidation worklist.
d55bfaa to
dc37cbf
Compare
Bugs fixed
A. Indexing hangs on
class << XwhereXis never defined. Minimal repro:With no
module Mathanywhere in the codebase, the resolver created a TODO forMath→ cleanup removed it (empty) → re-resolution re-created it → infinite loop.B. Singleton classes wrongly removed when one of their defining files is deleted but other signals still hold them alive. Example: deleting a bare
class Foo; endreopener would cascade-removeFoo::<Foo>even though another file defineddef self.baron it. Similarly for singletons kept alive by callers (Foo.new).C. Orphan TODOs left behind after ancestor delete+readd. During the delete phase,
create_todo_for_parentsynthesizes placeholders for temporarily unresolvable parents. After the ancestor's definer file is re-added, the real declarations resolve correctly but the stale TODOs lingered.D. Compact-notation TODO parents not cleaned up. Deleting the only file for
class A::B; endremovedA::Bbut left TODOAin the graph.Solution
Two structural changes:
is_removableas the single signal for "no longer in use," so singleton classes kept alive by members or inbound references aren't wrongly swept up (fixes B).Plus a targeted worklist fix:
How TODOs get removed
There are two mechanisms:
invalidate_declaration. Example: withclass A::B; endas the only file, deleting it removesA::Band then cascades to the TODOA(D).class << Math(A), the singleton attaches viaset_singleton_class_id, notadd_member, so TODOMathnever gains a member and no cascade ever fires. Every TODOcreate_todo_for_parentmakes is recorded intracked_todos; after resolution,cleanup_stale_todosdrains the set and removes entries that are still empty viainvalidate_graph.We can't decide the second case at TODO creation time — a later file could still add
module Mathand promote it. Sotracked_todosis a watch list (everything goes in), and the decision is deferred tocleanup_stale_todos.Singleton liveness
The new
is_removablepredicate onDeclarationis the single signal for "no longer in use":This protects
Foo::<Foo>from being removed when theclass << selffile is deleted whileFoo.newstill references it elsewhere, or while@x/def self.barstill live under the singleton (fixes B).Tests
16 new tests in
rust/rubydex/src/model/graph.rs. Each targets one of the bugs above or an adjacent invariant, notably:resolve_terminates_with_singleton_class_on_undefined_constant— the original infinite-loop repro (A)singleton_class_survives_when_reopener_is_deleted/..._when_singleton_definition_deleted_but_caller_remains/empty_singleton_removed_after_its_members_are_deleted— the retain-vs-collect matrix (B)no_orphan_todo_after_ancestor_definer_readded/compact_class_todo_chain_restored_after_ancestor_delete_readd— ancestor delete+readd (C)todo_parent_removed_after_compact_child_deleted/orphan_singleton_cleaned_up_when_owner_deleted/ancestor_singletons_cleaned_up_after_class_chain_deleted— cascade coverage (D)multi_round_deletion_matches_fresh_index— incremental-vs-fresh parity across edit sequences