fix: guard against disposed model in quick diff diff() (fixes #324537) - #324543
Open
vs-code-engineering[bot] wants to merge 1 commit into
Open
fix: guard against disposed model in quick diff diff() (fixes #324537)#324543vs-code-engineering[bot] wants to merge 1 commit into
vs-code-engineering[bot] wants to merge 1 commit into
Conversation
The diff() callback dereferenced this._model.textEditorModel.getVersionId() synchronously at the top of the withProgress task, before the existing disposal guard. When the TextFileEditorModel is disposed during the ThrottledDelayer window, textEditorModel returns null and getVersionId() throws a TypeError. Add a disposal guard clause before the dereference, returning null (already handled by the triggerDiff caller). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
Author
📬 CODENOTIFYThe following users are being notified based on files changed in this PR: @lszomoruMatched files:
|
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
QuickDiffModel.diff()throwsTypeError: Cannot read properties of null (reading 'getVersionId')atsrc/vs/workbench/contrib/scm/browser/quickDiffModel.ts:259. The method runs inside aprogressService.withProgress(...)task that is scheduled by a 200msThrottledDelayer. The first statement of that task dereferencesthis._model.textEditorModel.getVersionId()— before the method's existing disposal guard. When the underlyingTextFileEditorModelis disposed during the throttle window (file closed / reverted, editor destroyed), itstextEditorModelgetter returnsnull, and the synchronousgetVersionId()call throws.The declared type
IResolvedTextFileEditorModel.textEditorModel: ITextModelis a compile-time snapshot; disposal invalidates it at runtime. So this is a use-after-dispose lifecycle race that surfaces as a type-contractTypeError.Scenario in one sentence: the
TextFileEditorModelis disposed during theThrottledDelayer/withProgresswindow, itstextEditorModelgetter returnsnull, and the already-scheduleddiff()task then dereferences it at line 259 before reaching the disposal guard at line 261.Fixes #324537
Recommended reviewer:
@hedietCulprit Commit
a9f0f574fced779121cfbe595a039f420346894d— "adds proposed createQuickDiffInformation API, adopts it in markdown editor. (#323470)" by@hediet(Henning Dieterichs), 2026-06-29.Evidence (verified by diffing the file at
61f0639c, 2026-03-29, anda9daa80e, 2026-01-20, against the shipped insider commit3fbc14e6, 1.128.0-insider):withProgresstask began withconst originalURIs = await this.getQuickDiffsPromise();— there was no synchronoustextEditorModeldereference and noversionIdtracking, and_modelwas typedITextFileEditorModel._modelfield type toIResolvedTextFileEditorModel, added thechangesVersionIdgetter, and insertedconst versionId = this._model.textEditorModel.getVersionId();as the first line of the task — above the existingthis._disposed || this._model.isDisposed()guard.| nulltodiff()'s return type and the!resultbranch intriggerDiff(), but never actually returnednull— the disposed path was declared but never wired up. That is exactly the missing piece this fix completes.All three revisions predate the shipped build, confirming the crash line ships in
3fbc14e6.Code Flow
flowchart TD Edit[User edits / closes / reverts file] --> Trigger[triggerDiff schedules ThrottledDelayer 200ms] Dispose[TextFileEditorModel disposed during throttle window] --> Getter[textEditorModel getter returns null] Trigger --> Fire{Delayer fires} Fire --> Diff[diff runs inside progressService.withProgress] Diff --> Line259[line 259: this._model.textEditorModel.getVersionId] Getter --> Line259 Line259 --> Crash[TypeError: Cannot read properties of null reading getVersionId]Affected Files
src/vs/workbench/contrib/scm/browser/quickDiffModel.ts— crash site (diff(), line 259) and the fix.src/vs/workbench/services/textfile/common/textfiles.ts— declaresIResolvedTextFileEditorModel.textEditorModel: ITextModel(non-null narrowing of baseITextEditorModel.textEditorModel: ITextModel | null).src/vs/workbench/common/editor/textEditorModel.ts— the null mechanism: thetextEditorModelgetter returnsnullonce the handle is cleared on dispose.Repro Steps
QuickDiffModelis active.triggerDiff()schedules the 200msThrottledDelayer.TextFileEditorModelto be disposed (close / revert the editor, or otherwise destroy the model).diff()runs and evaluatesthis._model.textEditorModel.getVersionId()on the disposed model →textEditorModelisnull→TypeError.Timing-dependent (dispose must land inside the throttle/progress window), which matches a low-frequency telemetry error.
How the Fix Works
Chosen approach (
quickDiffModel.ts): add a disposal guard clause as the first statement inside thewithProgresstask, before thegetVersionId()dereference at the bypass site (quickDiffModel.ts:259):This fixes the lifecycle race at its source — the point where a disposed model is dereferenced — rather than guarding the crash-site symptom or coercing the null value downstream. It completes the disposal-safety design the culprit commit half-added:
diff()already declares a| nullreturn, andtriggerDiff()already treats!resultexactly like the disposed case (if (!result || this._disposed || ...) { return; }), so returningnullhere is the intended, already-handled signal. It reuses the establishedthis._disposed || this._model.isDisposed()predicate that the same method applies two lines below, so no new contract or state is introduced. After this change, thegetVersionId()line cannot run when the model is disposed, because the guard returnsnullfirst and the getter is never dereferenced on a disposed model.Alternatives considered:
textEditorModel?.getVersionId() ?? 0): rejected — it coerces the violating null to a benign substitute in the consumer and masks the use-after-dispose race instead of preventing it; a stale diff would still be computed against a dead model.null: workable but redundant;nullis already the method's declared disposed signal and is handled identically by the caller, sonullis the minimal, most faithful choice.Recommended Owner
@hediet(Henning Dieterichs) — author of the culprit commita9f0f574/ PR #323470.hdieterichs@microsoft.com); authored the shipped release commit3fbc14e6itself, which requires write access. Pass.microsoft/vscodewithin the last 90 days (e.g. 2026-07-03). Pass.First candidate in the Step 5 cascade and passes both gates, so no fall-through to the SCM area owner (lszomoru) was needed.