From 831d35e8f7f711689c8ec9e6a92fa25409d8b4b9 Mon Sep 17 00:00:00 2001 From: ketan0 Date: Thu, 24 Sep 2026 17:03:46 -0700 Subject: [PATCH] Release review language working copies when diff models are off screen Agent-Session: 01a0d12e-51ca-7230-9991-0ce7a73018b2 --- .../reviewLocalLanguageFeatures.test.ts | 191 ++++++++++++++++++ .../services/reviewLocalLanguageFeatures.ts | 124 +++++++----- .../services/reviewSourceModelLifecycle.ts | 47 +++++ 3 files changed, 312 insertions(+), 50 deletions(-) create mode 100644 apps/review-desktop/code-oss/src/vs/review/services/reviewLocalLanguageFeatures.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/services/reviewSourceModelLifecycle.ts diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewLocalLanguageFeatures.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewLocalLanguageFeatures.test.ts new file mode 100644 index 000000000..f0886b759 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewLocalLanguageFeatures.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRequire, registerHooks } from "node:module"; +import { Disposable } from "../../base/common/lifecycle.js"; +import { Position } from "../../editor/common/core/position.js"; +import { URI } from "../../base/common/uri.js"; + +const { JSDOM } = createRequire(import.meta.url)("jsdom"); +const dom = new JSDOM(""); +for (const key of ["window", "document", "HTMLElement", "HTMLCanvasElement", "Node", "MutationObserver", "Element", "navigator", "customElements", "UIEvent", "MouseEvent", "KeyboardEvent", "FocusEvent"] as const) { + Object.defineProperty(globalThis, key, { configurable: true, value: dom.window[key] }); +} +dom.window.matchMedia = () => ({ matches: false, addEventListener() { }, removeEventListener() { } }) as never; +registerHooks({ load(url, context, next) { + return url.endsWith(".css") ? { format: "module", source: "", shortCircuit: true } : next(url, context); +} }); +const { ReviewLocalLanguageFeatures } = await import("./reviewLocalLanguageFeatures.js"); + +function event() { + const listeners = new Set<(value: T) => void>(); + return { + event: (listener: (value: T) => void) => { + listeners.add(listener); + return { dispose: () => listeners.delete(listener) }; + }, + fire(value: T) { for (const listener of [...listeners]) listener(value); }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +function model(attached = false) { + const changed = event(); + const disposed = event(); + let isDisposed = false; + const text = "same pinned source"; + return { + uri: URI.parse("review-api-source://review/src/file.ts?version=1"), + isAttachedToEditor: () => attached, + isDisposed: () => isDisposed, + getVersionId: () => 1, + getTextBuffer: () => text, + getEOL: () => "\n", + equalsTextBuffer: (other: string) => other === text, + onDidChangeAttached: changed.event, + onWillDispose: disposed.event, + setAttached(value: boolean) { attached = value; changed.fire(); }, + dispose() { isDisposed = true; disposed.fire(); }, + } as any; +} + +function setup(input: ReturnType | ReturnType[]) { + const sourceModels = Array.isArray(input) ? input : [input]; + const added = event(); + const modelService = { getModels: () => sourceModels, onModelAdded: added.event, createModelReference: async () => { throw new Error("unexpected model reference"); } }; + const languages = Object.fromEntries(["hoverProvider", "definitionProvider", "typeDefinitionProvider", "implementationProvider", "referenceProvider"].map(key => [key, { + register: () => Disposable.None, + ordered: () => [], + }])) as any; + const local = { + uri: URI.file("/project/src/file.ts"), + isDisposed: () => false, + getVersionId: () => 1, + getTextBuffer: () => "same pinned source", + getEOL: () => "\n", + equalsTextBuffer: (other: string) => other === "same pinned source", + }; + const service = new ReviewLocalLanguageFeatures( + { onDidChangeConnection: () => Disposable.None } as any, + { createModelReference: async (uri: URI) => ({ object: { textEditorModel: { ...local, uri } }, dispose() { } }) } as any, + modelService as any, + languages, + { activateByEvent: async () => undefined } as any, + {} as any, + { files: { models: [], resolve: async () => undefined } } as any, + { onDidFilesChange: () => Disposable.None, exists: async () => true } as any, + { debug() { } } as any, + ); + return { service, sourceModel: sourceModels[0], sourceModels, local }; +} + +function source(local: any) { + let references = 1; + let disposed = false; + const disposal = deferred(); + const release = () => { + if (!disposed && --references === 0) { disposed = true; disposal.resolve(); } + }; + const source = { + identity: "identity", + root: URI.file("/project"), + reference: { object: { textEditorModel: local }, dispose() { } }, + retain() { + if (disposed) return undefined; + references++; + let released = false; + return { dispose() { if (!released) { released = true; release(); } } }; + }, + dispose: release, + isDisposed: () => disposed, + waitDisposed: () => disposal.promise, + }; + return source; +} + +test("cancellation while acquiring a review source releases the newly acquired native model", async (t) => { + const setupResult = setup(model()); + t.after(() => setupResult.service.dispose()); + const acquisition = deferred(); + const started = deferred(); + const actualSource = source(setupResult.local); + const internal = setupResult.service as any; + internal.environment = async () => ({ rootPath: "/project", identity: "identity" }); + internal.acquire = async () => { started.resolve(); return acquisition.promise; }; + const token = { isCancellationRequested: false }; + const pending = internal.withSource(setupResult.sourceModel, new Position(1, 1), token, async () => "should not run"); + await started.promise; + token.isCancellationRequested = true; + acquisition.resolve(actualSource); + assert.equal(await pending, undefined); + assert.equal(actualSource.isDisposed(), true, "the canceled request drops both its temporary retain and detached owner"); +}); + +test("detaching during an in-flight definition keeps its captured source until mapping completes", async (t) => { + const setupResult = setup(model()); + t.after(() => setupResult.service.dispose()); + const acquisition = deferred(); + const started = deferred(); + const entered = deferred(); + const acquired = source(setupResult.local); + const internal = setupResult.service as any; + internal.environment = async () => ({ rootPath: "/project", identity: "identity" }); + internal.acquire = async () => { started.resolve(); return acquisition.promise; }; + setupResult.sourceModel.setAttached(true); + await started.promise; + const mapping = deferred(); + let capturedSource: unknown; + const request = internal.withSource(setupResult.sourceModel, new Position(1, 1), { isCancellationRequested: false }, async (_local: unknown, _at: unknown, _review: unknown, captured: unknown) => { + assert.equal(captured, acquired); + capturedSource = captured; + entered.resolve(); + return mapping.promise; + }); + acquisition.resolve(acquired); + await entered.promise; + setupResult.sourceModel.setAttached(false); + assert.equal(acquired.isDisposed(), false, "the request retain survives detachment and source-map eviction"); + const locations = await internal.reviewLocations(setupResult.sourceModel, [{ uri: URI.file("/project/src/target.ts") }], { isCancellationRequested: false }, capturedSource); + assert.equal(locations[0].uri.scheme, "review-api-source", "definition mapping uses the source retained by this request after map eviction"); + mapping.resolve("mapped result"); + assert.equal(await request, "mapped result"); + assert.equal(acquired.isDisposed(), true, "mapping completion releases the final source reference"); +}); + +test("hundreds of unattached pinned models allocate no native source models; detaching releases the visible model", async (t) => { + const setupResult = setup(Array.from({ length: 800 }, () => model())); + t.after(() => setupResult.service.dispose()); + const acquired = source(setupResult.local); + const internal = setupResult.service as any; + internal.environment = async () => ({ rootPath: "/project", identity: "identity" }); + let acquireCount = 0; + internal.acquire = async () => { acquireCount++; return acquired; }; + assert.equal(acquireCount, 0, "the pinned comparison is not eagerly resolved into 800 native working copies"); + setupResult.sourceModel.setAttached(true); + await internal.localSource(setupResult.sourceModel, true); + assert.equal(acquireCount, 1); + setupResult.sourceModel.setAttached(false); + await acquired.waitDisposed(); + assert.equal(acquired.isDisposed(), true); +}); + +test("disposing a review model or the service releases its warm native source", async (t) => { + for (const disposeOwner of ["model", "service"] as const) { + const result = setup(model()); + const acquired = source(result.local); + const internal = result.service as any; + internal.environment = async () => ({ rootPath: "/project", identity: "identity" }); + internal.acquire = async () => acquired; + result.sourceModel.setAttached(true); + await internal.localSource(result.sourceModel, true); + if (disposeOwner === "model") result.sourceModel.dispose(); + else result.service.dispose(); + await acquired.waitDisposed(); + assert.equal(acquired.isDisposed(), true, `${disposeOwner} disposal releases the working copy`); + result.service.dispose(); + } +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewLocalLanguageFeatures.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewLocalLanguageFeatures.ts index 696932240..1b8f9ca09 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewLocalLanguageFeatures.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewLocalLanguageFeatures.ts @@ -1,5 +1,5 @@ import type { CancellationToken } from "../../base/common/cancellation.js"; -import { Disposable, DisposableStore, type IReference } from "../../base/common/lifecycle.js"; +import { Disposable, DisposableStore, RefCountedDisposable, toDisposable, type IDisposable, type IReference } from "../../base/common/lifecycle.js"; import { URI } from "../../base/common/uri.js"; import { Position } from "../../editor/common/core/position.js"; import type { Hover, LocationLink } from "../../editor/common/languages.js"; @@ -23,11 +23,13 @@ import { IReviewDesktopConnectionService } from "./reviewDesktopConnectionServic import { withCurrentLocalContext } from "./reviewLocalRequest.js"; import { acquireReviewLanguageRoot } from "./reviewLocalWorkspace.js"; import { ReviewLanguageEnvironmentRequests } from "./reviewLanguageEnvironmentRequests.js"; +import { watchAttachedReviewModels, withRetainedSource } from "./reviewSourceModelLifecycle.js"; interface LocalSource { identity: string; root: URI; reference: IReference; + retain(): IDisposable | undefined; dispose(): void; } @@ -64,12 +66,10 @@ export class ReviewLocalLanguageFeatures extends Disposable { this._register(files.onDidFilesChange(event => { if ([...this.roots.keys()].some(root => event.affects(URI.parse(root)))) this.generation++; })); - // Warm language servers while source is being displayed, not at the first click. - const warm = (model: ITextModel) => { - if ([REVIEW_API_SOURCE_SCHEME, REVIEW_LANGUAGE_SOURCE_SCHEME].includes(model.uri.scheme)) void this.localSource(model); - }; - this._register(modelService.onModelAdded(warm)); - modelService.getModels().forEach(warm); + // Diff models include off-screen files; only attached editors warm native models. + this._register(watchAttachedReviewModels(modelService, [REVIEW_API_SOURCE_SCHEME, REVIEW_LANGUAGE_SOURCE_SCHEME], + model => { void this.localSource(model, true); }, + model => this.releaseSource(model))); for (const scheme of [REVIEW_API_SOURCE_SCHEME, REVIEW_LANGUAGE_SOURCE_SCHEME]) { const selector = { scheme, exclusive: true }; this._register(languages.hoverProvider.register(selector, { provideHover: (model, position, token) => this.hover(model, position, token) })); @@ -81,9 +81,9 @@ export class ReviewLocalLanguageFeatures extends Disposable { this._register(languages.typeDefinitionProvider.register(target, { provideTypeDefinition: (model, position, token) => this.locations(model, position, token, "type") })); this._register(languages.implementationProvider.register(target, { provideImplementation: (model, position, token) => this.locations(model, position, token, "implementation") })); this._register(languages.referenceProvider.register(target, { - provideReferences: (model, position, context, token) => this.withSource(model, position, token, async (local, at, pinned) => { + provideReferences: (model, position, context, token) => this.withSource(model, position, token, async (local, at, pinned, source) => { const results = await Promise.all(languages.referenceProvider.ordered(local).map(provider => provider.provideReferences(local, at, context, token))); - return this.reviewLocations(pinned, results.flatMap(result => result ?? []), token); + return this.reviewLocations(pinned, results.flatMap(result => result ?? []), token, source); }), })); } @@ -104,12 +104,19 @@ export class ReviewLocalLanguageFeatures extends Disposable { }, validate); } - private async localSource(model: ITextModel): Promise { - if (new URLSearchParams(model.uri.query).has("empty")) return undefined; + private releaseSource(model: ITextModel): void { + const entry = this.sources.get(model); + if (!entry) return; + this.sources.delete(model); + void entry.pending.then(source => source?.dispose()); + } + + private async localSource(model: ITextModel, warming = false): Promise { + if (this._store.isDisposed || model.isDisposed() || new URLSearchParams(model.uri.query).has("empty")) return undefined; try { const epoch = this.environments.generation; const context = await this.environment(model); - if (epoch !== this.environments.generation || model.isDisposed()) return undefined; + if (epoch !== this.environments.generation || this._store.isDisposed || model.isDisposed() || (warming && !model.isAttachedToEditor())) return undefined; const cached = this.sources.get(model); if (cached && cached.identity === context?.identity && cached.rootPath === context.rootPath) return cached.pending; if (cached) { @@ -156,49 +163,67 @@ export class ReviewLocalLanguageFeatures extends Disposable { }); const reference = owned.add(await this.models.createModelReference(resource)); owned.add(reference.object.textEditorModel.onDidChangeContent(() => this.generation++)); - owned.add(model.onWillDispose(() => { this.sources.delete(model); owned.dispose(); })); - if (model.isDisposed()) { owned.dispose(); return undefined; } + if (model.isDisposed()) { owned.dispose(); return undefined; } await this.extensions.activateByEvent(`onLanguage:${reference.object.textEditorModel.getLanguageId()}`); if (model.isDisposed()) { owned.dispose(); return undefined; } - return { root, reference, identity: context.identity, dispose: () => owned.dispose() }; + const lifetime = new RefCountedDisposable(owned); + const owner = toDisposable(() => lifetime.release()); + return { + root, reference, identity: context.identity, + retain: () => { + if (owned.isDisposed) return undefined; + lifetime.acquire(); + return toDisposable(() => lifetime.release()); + }, + dispose: () => owner.dispose(), + }; } catch (error) { owned.dispose(); throw error; } } - private async withSource(model: ITextModel, position: Position, token: CancellationToken, run: (local: ITextModel, at: Position, review: ITextModel) => Promise): Promise { + private async withSource(model: ITextModel, position: Position, token: CancellationToken, run: (local: ITextModel, at: Position, review: ITextModel, source: LocalSource | undefined) => Promise): Promise { if (token.isCancellationRequested || model.isDisposed()) return undefined; - if (model.uri.scheme === "file") return withCurrentLocalContext([model], token, () => this.generation, async () => run(model, position, model)); + if (model.uri.scheme === "file") return withCurrentLocalContext([model], token, () => this.generation, async () => run(model, position, model, undefined)); const epoch = this.environments.generation; const source = await this.localSource(model); - if (!source || token.isCancellationRequested || model.isDisposed()) return undefined; - const local = source.reference.object.textEditorModel; - if (!await this.files.exists(local.uri)) { this.uncertainRoots.add(source.root.toString()); return undefined; } - if (this.uncertainRoots.has(source.root.toString())) { - // An open dependency can outlive a removed checkout and miss subsequent - // watcher updates. Until watcher readiness is authoritative, revalidate - // clean native buffers before querying; never replace a dirty buffer. - const prefix = source.root.path.replace(/\/$/, "") + "/"; - await Promise.all(this.textFiles.files.models.filter(file => !file.isDirty() && file.resource.scheme === "file" && file.resource.path.startsWith(prefix)) - .map(file => this.textFiles.files.resolve(file.resource, { reload: { async: false } }))); + if (!source) return undefined; + // Detaching the editor can release its warm owner while a request awaits + // disk or an extension provider. Keep that request's model/root alive. + try { + return await withRetainedSource(source, async () => { + if (token.isCancellationRequested || model.isDisposed()) return undefined; + const local = source.reference.object.textEditorModel; + if (!await this.files.exists(local.uri)) { this.uncertainRoots.add(source.root.toString()); return undefined; } + if (this.uncertainRoots.has(source.root.toString())) { + // An open dependency can outlive a removed checkout and miss subsequent + // watcher updates. Until watcher readiness is authoritative, revalidate + // clean native buffers before querying; never replace a dirty buffer. + const prefix = source.root.path.replace(/\/$/, "") + "/"; + await Promise.all(this.textFiles.files.models.filter(file => !file.isDirty() && file.resource.scheme === "file" && file.resource.path.startsWith(prefix)) + .map(file => this.textFiles.files.resolve(file.resource, { reload: { async: false } }))); + } + // Resolve current disk contents, preserving any unsaved local editor buffer. + await this.textFiles.files.resolve(local.uri, { reload: { async: false } }); + return await withCurrentLocalContext([model, local], token, () => this.generation, async () => { + // The language server must see exactly the source displayed in the review. + if (!sameSource(model, local)) return undefined; + const result = await run(local, position, model, source); + const current = await this.environment(model, true).catch(() => undefined); + if (epoch === this.environments.generation && isSameRoot(current?.rootPath, source.root) && current?.identity === source.identity) return result; + // Failed validation must also release the old workspace/watchers. Keeping + // them after deletion can leave the language server blind to later edits. + const cached = this.sources.get(model); + if (cached?.identity === source.identity && isSameRoot(cached.rootPath, source.root)) { + this.uncertainRoots.add(source.root.toString()); + this.sources.delete(model); + void cached.pending.then(value => value?.dispose()); + this.generation++; + } + return undefined; + }); + }); + } finally { + if (!model.isAttachedToEditor()) this.releaseSource(model); } - // Resolve current disk contents, preserving any unsaved local editor buffer. - await this.textFiles.files.resolve(local.uri, { reload: { async: false } }); - return withCurrentLocalContext([model, local], token, () => this.generation, async () => { - // The language server must see exactly the source displayed in the review. - if (!sameSource(model, local)) return undefined; - const result = await run(local, position, model); - const current = await this.environment(model, true).catch(() => undefined); - if (epoch === this.environments.generation && isSameRoot(current?.rootPath, source.root) && current?.identity === source.identity) return result; - // Failed validation must also release the old workspace/watchers. Keeping - // them after deletion can leave the language server blind to later edits. - const cached = this.sources.get(model); - if (cached?.identity === source.identity && isSameRoot(cached.rootPath, source.root)) { - this.uncertainRoots.add(source.root.toString()); - this.sources.delete(model); - void cached.pending.then(value => value?.dispose()); - this.generation++; - } - return undefined; - }); } private hover(model: ITextModel, position: Position, token: CancellationToken): Promise { @@ -212,18 +237,17 @@ export class ReviewLocalLanguageFeatures extends Disposable { } private async locations(model: ITextModel, position: Position, token: CancellationToken, kind: "definition" | "type" | "implementation"): Promise { - return this.withSource(model, position, token, async (local, at, pinned) => { + return this.withSource(model, position, token, async (local, at, pinned, source) => { const results = kind === "definition" ? await getDefinitionsAtPosition(this.languages.definitionProvider, local, at, false, token) : kind === "type" ? await getTypeDefinitionsAtPosition(this.languages.typeDefinitionProvider, local, at, false, token) : await getImplementationsAtPosition(this.languages.implementationProvider, local, at, false, token); - return this.reviewLocations(pinned, results, token); + return this.reviewLocations(pinned, results, token, source); }); } /** Keep navigation in the same saved version/side only when destination contents match. */ - private async reviewLocations(pinned: ITextModel, locations: T[], token: CancellationToken): Promise { + private async reviewLocations(pinned: ITextModel, locations: T[], token: CancellationToken, source?: LocalSource): Promise { if (![REVIEW_API_SOURCE_SCHEME, REVIEW_LANGUAGE_SOURCE_SCHEME].includes(pinned.uri.scheme)) return locations; - const source = await this.sources.get(pinned)?.pending; if (!source) return []; const prefix = source.root.path.replace(/\/$/, "") + "/"; // References often share a file. Resolve and compare each destination once per request. diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewSourceModelLifecycle.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewSourceModelLifecycle.ts new file mode 100644 index 000000000..0f0ce081e --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewSourceModelLifecycle.ts @@ -0,0 +1,47 @@ +import { DisposableStore, toDisposable, type IDisposable } from "../../base/common/lifecycle.js"; +import type { ITextModel } from "../../editor/common/model.js"; +import type { IModelService } from "../../editor/common/services/model.js"; + +/** Tracks only review models currently attached to an editor. */ +export function watchAttachedReviewModels( + models: Pick, + schemes: readonly string[], + attached: (model: ITextModel) => void, + detached: (model: ITextModel) => void, +): IDisposable { + const store = new DisposableStore(); + const active = new Set(); + store.add(toDisposable(() => { + for (const model of active) detached(model); + active.clear(); + })); + const watch = (model: ITextModel) => { + if (!schemes.includes(model.uri.scheme)) return; + const owned = store.add(new DisposableStore()); + const update = () => { + if (model.isAttachedToEditor()) { + if (!active.has(model)) { active.add(model); attached(model); } + } else if (active.delete(model)) detached(model); + }; + owned.add(model.onDidChangeAttached(update)); + owned.add(model.onWillDispose(() => { + if (active.delete(model)) detached(model); + store.delete(owned); + owned.dispose(); + })); + update(); + }; + store.add(models.onModelAdded(watch)); + models.getModels().forEach(watch); + return store; +} + +export async function withRetainedSource(source: { retain(): IDisposable | undefined }, run: () => Promise): Promise { + const reference = source.retain(); + if (!reference) return undefined; + try { + return await run(); + } finally { + reference.dispose(); + } +}