fix(property-changeset): Prevent duplicate array insertions during rebase#26820
fix(property-changeset): Prevent duplicate array insertions during rebase#26820bhavin121 wants to merge 7 commits into
Conversation
…ncurrent rebase Add advanceRebaseIteratorB skip mechanism to avoid emitting a paired INSERT twice — once from the splitOverlapping special case and again from the iterator's standalone yield. Mirrors the existing skip logic in _performApplyAfterOnPropertyArray. Fixes the Invalid Array Length bug caused by duplicate elements.
|
Hi @anthony-murphy, When you get a chance, could you please take a look at this PR? |
|
Hi @anthony-murphy, any update on this PR. |
|
PR description includes:
That sound like a good idea (a regression test for this bug). There however does not seem to be one included in this PR.
I'm not sure if that's intended to be part of the above mentioned test (which is missing) or some separate verification, but might be good to ensure that is done as well (and clarified in the PR description) |
| (opB as any).removeInsertOperation && | ||
| segment.op !== undefined && | ||
| skipIteratorBOperation === undefined && | ||
| segment.op.operation === (opB as any).removeInsertOperation |
There was a problem hiding this comment.
These casts to any are extremally non-type safe.
Please avoid introducing new code which defeats type safety.
If it is impractical to be type safe here, please document why that is the case, why its actually safe, and do something less unsafe (like cast to some specific type which exposes removeInsertOperation with the correct tying instead of it also being just any.
There was a problem hiding this comment.
Thanks for catching this. I've replaced (opB as any) with (opB as NoneNOPOperation) in the new advanceRebaseIteratorB, and also updated the pre-existing casts in advanceIteratorB (line ~1568) to match.
NoneNOPOperation is the union RemoveOperation | InsertOperation | ModifyOperation — all three subtypes define removeInsertOperation?, so the cast is type-safe. The only member of GenericOperation that lacks this property is NOPOperation, but in that case removeInsertOperation evaluates to undefined, and the if condition short-circuits harmlessly at the first check.
Commit: 87206eb
| let skipIteratorBOperation; | ||
| const opB = iteratorB.opDescription; | ||
|
|
||
| const advanceRebaseIteratorB = () => { |
There was a problem hiding this comment.
This code is relatively complex, and, given the bug, somewhat fragile. I think it would be a good idea to include some comments on these new locals (skipIteratorBOperation, opB, advanceRebaseIteratorB) to explain their semantics.
Without such details, it's quite hard to know if this code is correct, both for current reviewers, and future readers and editors of this code.
There was a problem hiding this comment.
Agreed — the two-stage skip mechanism is non-obvious without context. I've added doc comments on all three locals:
opB: Reference to iteratorB's mutable operation descriptor (shared object updated in-place on eachnext()call).skipIteratorBOperation: Two-stage skip state — records the INSERT's operation reference whensplitOverlapping's special case has already emitted it, so the next advancement knows to skip past the duplicate yield.advanceRebaseIteratorB: The skip-aware advancement helper. Stage 1 records, Stage 2 skips.
I've also added equivalent comments to the existing advanceIteratorB in _performApplyAfterOnPropertyArray for consistency, and noted that the two closures are intentionally kept separate (they capture different local scopes).
Commit: 87206eb
|
Hi! Thank you for opening this PR. Want me to review it? Based on the diff (121 lines, 2 files), I've queued these reviewers:
How this works
|
There was a problem hiding this comment.
Pull request overview
Fixes an array changeset rebasing issue in PropertyDDS where a paired INSERT (from a remove+insert “replace” pattern) could be emitted twice, leading to downstream crashes (e.g., RangeError: Invalid array length) in concurrent editing scenarios.
Changes:
- Introduces
advanceRebaseIteratorB()in_rebaseArrayChangeSetForPropertyto skip a duplicated iterator yield whensplitOverlapping()already emitted the paired INSERT. - Replaces direct
iteratorB.next()advancement sites with the new helper to ensure consistent skip behavior.
| const advanceRebaseIteratorB = () => { | ||
| if ( | ||
| (opB as any).removeInsertOperation && | ||
| segment.op !== undefined && | ||
| skipIteratorBOperation === undefined && | ||
| segment.op.operation === (opB as any).removeInsertOperation | ||
| ) { | ||
| skipIteratorBOperation = segment.op.operation; | ||
| } else { | ||
| iteratorB.next(); | ||
| if (skipIteratorBOperation && opB.operation === skipIteratorBOperation) { | ||
| iteratorB.next(); | ||
| } | ||
| skipIteratorBOperation = undefined; | ||
| getRangeForAppliedOperation(opB, rangeB, undefined, in_options); | ||
| } | ||
| }; | ||
|
|
There was a problem hiding this comment.
Good observation. The two helpers (advanceIteratorB and advanceRebaseIteratorB) are structurally identical, but they close over different local variables in different function scopes — extracting a shared helper would require passing ~6 mutable references via a parameter object, which would add complexity and indirection for minimal benefit (~15 lines of code).
Additionally, the two functions operate in different contexts (apply-after vs. rebase) that could theoretically diverge in the future. Refactoring advanceIteratorB, which has been stable for years, carries more risk than the duplication itself.
I've added a comment in both helpers noting the relationship and explaining why they are kept separate.
| skipIteratorBOperation === undefined && | ||
| segment.op.operation === (opB as any).removeInsertOperation | ||
| ) { | ||
| skipIteratorBOperation = segment.op.operation; |
There was a problem hiding this comment.
Added two regression tests in array.spec.ts:
-
"Concurrent replace (remove+insert) vs multi-position insert should not produce duplicate inserts" — directly verifies that the rebased output contains exactly one INSERT (not two), and that positions are correctly shifted.
-
"Convergence: concurrent replace (remove+insert) vs multi-position insert" — uses the existing
testRebasedApplieshelper to verify that both paths (local-then-delta vs. remote-then-rebased-local) converge to the same final state.
Both tests pass (ESM and CJS). All 268 existing tests continue to pass with no regressions.
Commit: 87206eb
…atorB - Replace `as any` casts with `as NoneNOPOperation` for type safety in both advanceRebaseIteratorB and the existing advanceIteratorB - Add documentation comments explaining the semantics of skipIteratorBOperation, opB, and the two-stage skip mechanism in both helper functions - Add regression tests for the concurrent replace (remove+insert) vs multi-position insert scenario that triggered duplicate INSERT emissions Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Fixes the Invalid Array Length crash (
RangeError: Invalid array length) that occurs during array changeset rebasing in PropertyDDS concurrent editing scenarios.The root cause is duplicate INSERT operations being emitted by
_rebaseArrayChangeSetForProperty. This fix adds anadvanceRebaseIteratorBskip mechanism that prevents a paired INSERT from being emitted twice — once from thesplitOverlappingspecial case and again from the iterator's standalone yield.Mirrors the existing
advanceIteratorBskip logic already proven correct in_performApplyAfterOnPropertyArray.Problem
When two clients concurrently edit the same array — one replacing elements (remove + insert at the same position) and another inserting elements — the rebased changeset contains duplicate insertions. These duplicate INSERT operations at the same position then flow into
mergeWithLastIfPossible, where two Map entries end up pointing to the samemetaInformationobject (and thus the samerebasedRemoveInsertRangesarray). The merge loop pushes elements fromnextRangeintopreviousRange, but since they are the same array reference, each push grows the array being iterated — creating an infinite loop that runs until the array exceeds JavaScript's maximum size (~4.3 billion elements), crashing with:This crashes
SharedPropertyTree.processCore, disconnecting the client from the collaborative session.Reproduction scenario:
Root Cause
In
_rebaseArrayChangeSetForProperty(array.ts), when thesplitOverlappingspecial case emits a paired INSERT (because its position fell behind the advancing REMOVE), it sets the segment flag tocompleteB. This triggersiteratorB.next(), which yields the same INSERT again (since the iterator only peeked at it, not consumed it). Without a skip mechanism, the INSERT gets emitted a second time.The duplicate INSERT at the same position is the upstream root cause. The infinite loop in
mergeWithLastIfPossible(via sharedmetaInformationreferences) is a downstream consequence — fixing the duplicate emission eliminates the crash entirely without needing any changes tomergeWithLastIfPossibleorgetRangeForAppliedOperation.Fix
Added ~15 lines of new code — an
advanceRebaseIteratorBclosure with a two-stage skip:Stage 1 (record): When the just-emitted segment matches the paired
removeInsertOperation(by reference equality), record it for skipping and do not advance the iterator.Stage 2 (skip): On the next advancement, call
next(), detect the duplicate INSERT via the recorded reference, callnext()again to skip past it, then clear the skip state.Replaced the two direct
iteratorB.next()call sites (completeB/completeBpartOfAandcompleteAcompleteB) withadvanceRebaseIteratorB().Changed File
Test Plan
Run existing
array.spec.tsrebase tests to verify no regressionsAdd a test case for the reproduction scenario: concurrent replace (remove+insert at position 0) vs. multi-position insert, verifying the rebased output contains exactly one copy of the INSERT and no
Invalid array lengthcrashVerify convergence: apply Client 2's change then the rebased Client 1 change, and confirm the final array matches the expected state