fix: don't report merged multi-reason content changes as unexpected (fixes #321636)#321642
Open
vs-code-engineering[bot] wants to merge 1 commit into
Open
Conversation
…ixes #321636) ModelTracker in mainThreadDocuments asserted that every content change event carries exactly one detailed reason. Deferred-emit merging (InternalModelContentChangeEvent._mergeChangeEvents) legitimately concatenates detailedReasons when multiple model mutations are flushed in a single begin/endDeferredEmit block, e.g. during undo/redo. The assertion therefore fired onUnexpectedError for a valid, contract-allowed event (detailedReasons is a TextModelEditSource[]). Remove the false-positive assertion and keep forwarding the first reason to the extension host, hardening the read with optional chaining. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.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.
Summary
ModelTrackerinmainThreadDocuments.tslistens toITextModel.onDidChangeContentand asserted that every event carries exactly one detailed edit reason (e.detailedReasonsChangeLengths.length !== 1→onUnexpectedError). That assumption is false.IModelContentChangedEvent.detailedReasonsis typed as aTextModelEditSourcearray, andDidChangeContentEmitterdeliberately merges deferred content-change events, concatenating theirdetailedReasons/detailedReasonsChangeLengthsarrays. When several model mutations are flushed inside a singlebegin/endDeferredEmitblock — as happens during undo/redo — the fired event legitimately carries two or more reasons (telemetry showsunknown,applyEdits), so the assertion raisesError: Unexpected reasons: unknown,applyEditsinto error telemetry even though nothing actually went wrong (the document still updates correctly). The fix removes the false-positive assertion and keeps forwarding the first reason, which is all the proposedTextDocumentChangeEvent.detailedReasonAPI can express.Fixes #321636
Recommended reviewer:
@hedietCulprit Commit
488d6df7955ca02076fd1da3fb8cbdf2a252a407— "Implements edit source tracking through proposed API (#252430)" by Henning Dieterichs (@hediet), 2025-06-26.This single commit introduced both sides of the contradiction:
src/vs/editor/common/textModelEvents.ts(_mergeChangeEvents→detailedReasons: a.detailedReasons.concat(b.detailedReasons)), which makes a multi-reason event a first-class, expected state; andsrc/vs/workbench/api/browser/mainThreadDocuments.ts(if (e.detailedReasonsChangeLengths.length !== 1) { onUnexpectedError(...) }), which declares that very same state illegal.Code Flow
flowchart TD A["User triggers Undo (coreCommands.ts)"] --> B["TextModel.undo()"] B --> C["UndoRedoService.undo()"] C --> D["SingleModelEditStackElement.undo (editStack.ts:220)"] D --> E["TextModel._applyUndo (textModel.ts:1441)"] E --> F["_applyUndoRedoEdits: beginDeferredEmit (textModel.ts:1465)"] F --> G["_doApplyEdits with EditSources.applyEdits() (textModel.ts:1472)"] F --> H["second mutation in same block, e.g. EditSources.unknown via pushEditOperation (editStack.ts:428) or setEOL"] G --> I["DidChangeContentEmitter merges the deferred events"] H --> I I --> J["_mergeChangeEvents concatenates detailedReasons (textModelEvents.ts:590)"] J --> K["endDeferredEmit fires ONE event whose detailedReasons has length 2"] K --> L["ModelTracker.onDidChangeContent (mainThreadDocuments.ts:100)"] L --> M["length !== 1 -> onUnexpectedError: 'Unexpected reasons: unknown,applyEdits'"]Affected Files
src/vs/workbench/api/browser/mainThreadDocuments.ts— crash site and the file changed by this PR.ModelTracker'sonDidChangeContentlistener held the wrong single-reason assumption.src/vs/editor/common/textModelEvents.ts—_mergeChangeEvents(line ~590) concatenatesdetailedReasons; this is the mechanism that produces multi-reason events. Working as designed, not modified.src/vs/editor/common/model/textModel.ts—_applyUndoRedoEdits(line ~1465) opens one deferred-emit block that appliesEditSources.applyEdits()and may also runsetEOL. Producer, not modified.src/vs/editor/common/model/editStack.ts—pushEditOperation(line ~428) defaults its reason toEditSources.unknown({ name: 'pushEditOperation' }), the origin of the leadingunknownreason. Not modified.Repro Steps
ModelTrackeris attached to every text model mirrored to the extension host, so any synchronized document exercises this path; an extension using the proposedtextDocumentChangeReasonAPI makes the forwarded reason observable.pushEditOperation(defaultunknownsource) immediately followed by an undo that appliesEditSources.applyEdits().Error: Unexpected reasons: unknown,applyEditsreported throughonUnexpectedErrorinto error telemetry. The document content updates correctly; only the spurious error is logged.Note: the exact mutation contributing the leading
unknownreason depends on the active edit providers. The structural trigger is any deferred-emit block that flushes two differently-sourced mutations, which is why the reason pair can vary while the assertion fires identically.How the Fix Works
Chosen approach (
src/vs/workbench/api/browser/mainThreadDocuments.ts):if (e.detailedReasonsChangeLengths.length !== 1) { onUnexpectedError(...) }block. Its premise — a content-change event always has exactly one detailed reason — directly contradicts the contract the same feature established:detailedReasonsis declared as an array and_mergeChangeEventsis purpose-built to concatenate those arrays when deferred emits merge. A merged, multi-reason event is therefore a valid value, not invalid data, so the correct place to fix is the consumer holding the wrong assumption — not a guard bolted onto a producer that is behaving exactly as designed (fix where the wrong assumption lives, not at a non-existent bad-data producer).onUnexpectedErrorcall was a development-time "I don't expect this yet" marker, and the telemetry it produced is a false positive — the model update itself always succeeds. NologService.erroror genuine diagnostic is removed, and this path was never a functional failure.e.detailedReasons[0].metadatatoe.detailedReasons[0]?.metadata. The deleted block was the only thing guarding that[0]access; optional chaining keeps the (contractually impossible but defensive) empty-array case from throwing a freshTypeError, while forwarding the first reason to the extension host exactly as before for the normal single-reason case.After this change,
mainThreadDocuments.tscan no longer raiseUnexpected reasons: ...for content-change events whosedetailedReasonsarray has length other than one, because that array is an explicitly supported multi-value contract populated by the merge attextModelEvents.ts:590; the event's first reason is still forwarded unchanged.Alternatives considered:
detailedReason): rejected — the proposedvscode.TextDocumentChangeEvent.detailedReasonAPI is intentionally singular, so this would expand a proposed API surface and touch the protocol, the ext-host converter, and the.d.ts, far beyond a focused error fix.Recommended Owner
@hediet(Henning Dieterichs) — authored488d6df7/ #252430, which introduced edit-source tracking, thedetailedReasonsmerge, and the consumer assertion fixed here. He owns the proposedtextDocumentChangeReasonAPI and is best placed to confirm that forwarding the first reason (rather than surfacing all reasons) is the desired extension-host behavior.