Skip to content

test: property tests for store structures (+ fix PathRefStore ref leak)#35

Open
danReynolds wants to merge 1 commit into
mainfrom
claude/store-property-tests
Open

test: property tests for store structures (+ fix PathRefStore ref leak)#35
danReynolds wants to merge 1 commit into
mainfrom
claude/store-property-tests

Conversation

@danReynolds
Copy link
Copy Markdown
Owner

Summary

Adds model-based property tests for the three path-keyed store structures — PathRefStore, ValueStore, and ValueRefStore — and fixes a PathRefStore ref-count leak the harness found on its first run.

These stores are where the audit's bugs clustered (ref counting, tree restructuring, recursive delete): invariant-maintaining state machines whose bugs hide in specific operation sequences, not in control flow. Example-based tests miss those; property tests explore the sequence space.

The harness

Each test drives a randomized sequence of operations against the real store and a trivial reference model, then asserts they agree after every step and that the structure empties out completely. The model is recomputed from scratch each step, so it's an independent oracle for the stores' incremental bookkeeping. A small segment/value alphabet is used deliberately to force path collisions and shared prefixes. On failure, the seed and full operation log are printed for replay.

  • PathRefStore — random inc/dec (dec only targets inc'd paths, mirroring real usage); asserts has / isEmpty.
  • ValueStore — random write / recursive delete; asserts get / hasValue / hasPath / getChildValues / isEmpty.
  • ValueRefStore — random write / recursive delete; asserts the __refs value→count aggregation in every subtree (getRefs / extractValues) matches the values actually present — i.e. the aggregation maintained across the recursive-delete ref propagation.

Scope: write and recursive delete. Non-recursive delete has subtler semantics (prunes a node's immediate values while keeping deeper descendants) and is left for a dedicated model.

The bug it found

In PathRefStore._dec, when a path had become a transient node — it and a descendant were both inc'd, then the descendant was dec'd — dec'ing the path itself reached the Map branch, which signalled removal via a return value that the top-level dec ignores. So the node lingered: has(path) stayed true and the ref count leaked (the node was never removed and the parent's ref was never decremented). The int branch immediately above already handled its analogous case correctly with a direct node.remove(segment).

Effect in the library: PathRefStore backs observer/query dependency tracking, so a stale true from has causes spurious rebroadcasts and a slow ref-count/memory leak as dependencies churn.

Reachable with only well-formed inc/dec pairs:

store.inc('c');
store.inc('c__c');
store.dec('c__c');
store.inc('a');   // keeps the root ref > 1 so the early-return doesn't mask it
store.dec('c');   // before: has('c') == true (leaked); after: removed

Fix: remove the node in place when its last reference is released (or drop only its ref key if it still routes to live descendants), symmetric with the int branch. A focused regression test pins the exact scenario.

Test plan

  • flutter test test/core/store/store_property_test.dart — 3 property tests × 200 seeds each, green
  • Confirmed the harness fails against the unfixed _dec (caught it on seed 0)
  • flutter test test/core — green (110 tests; the one intermittent failure seen is the pre-existing broadcast-timing flake, in the broadcast path, unrelated to _dec — confirmed by 5+ clean reruns)

Generated by Claude Code

…thRefStore ref leak

Adds randomized model-based property tests for PathRefStore, ValueStore,
and ValueRefStore. Each drives random operation sequences against the
real store and a from-scratch reference model, asserting they agree and
that the structures empty out fully. The seed and op log are printed on
failure for replay.

The harness immediately found a PathRefStore bug. In `_dec`, when a path
had become a transient node (it and a descendant were inc'd, then the
descendant dec'd), dec'ing the path itself hit the Map branch, which
signalled removal via a return value — but the top-level `dec` ignores
that return, so the node lingered: `has` stayed true and the ref count
leaked. The int branch directly above already handled its analogous case
correctly. Fixed to remove the node in place (or drop just its ref key if
it still routes to live descendants), symmetric with the int branch.

Reachable with only well-formed inc/dec pairs, e.g.
inc(c); inc(c__c); dec(c__c); inc(a); dec(c). A focused regression test
pins the scenario.
Copilot AI review requested due to automatic review settings May 26, 2026 16:16
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds model-based property tests for the library’s path-keyed store structures (PathRefStore, ValueStore, ValueRefStore) and applies a fix to PathRefStore._dec intended to prevent a ref-count / node-linger leak discovered by the new harness.

Changes:

  • Add randomized, model-based property tests covering inc/dec for PathRefStore and write/delete(recursive) for ValueStore and ValueRefStore.
  • Add a focused regression test for the transient-node decrement scenario in PathRefStore.
  • Update PathRefStore._dec to remove a map child node in-place when its last reference is released.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
test/core/store/store_property_test.dart Introduces model-based randomized property tests for the three store types.
test/core/store/path_ref_store_test.dart Adds a regression test to pin the transient-node decrement/ref-leak scenario.
lib/store/path_ref_store.dart Adjusts _dec behavior for map children when releasing the last reference.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +112 to 124
final Map child = node[segment];
if (child[_refKey] == 1) {
// Releasing the last reference to this node. If it has no descendants,
// remove it entirely; otherwise it still routes to live descendants, so
// drop only its ref key and keep it as a transient node.
if (child.length == 1) {
node.remove(segment);
} else {
child.remove(_refKey);
}
} else {
node[segment][_refKey]--;
child[_refKey]--;
}
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.

3 participants