diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 208313dac3502..1427586fc6e91 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -221,8 +221,8 @@ export interface TranspileOutput { } // @sync-only-start -// export { all } from "./generatorSupport.ts"; -// import {all, type ExecutedGeneratorsResults, type APIRequestGenerator} from "./generatorSupport.ts"; +// export { all, defer } from "./generatorSupport.ts"; +// import {executeRequestGenerators, type ExecutedGeneratorsResults, type APIRequestGenerator} from "./generatorSupport.ts"; // @sync-only-end export class API implements FormatDiagnosticsHost { @@ -260,12 +260,7 @@ export class API implements FormatDiagnosticsHo // @sync-skip-block-end // @sync-only-start // batch(...requestGenerators: T): ExecutedGeneratorsResults { - // const batches = all(...requestGenerators); - // let state = batches.next(); - // while (!state.done) { - // state = batches.next(this.client.batchRequests(state.value).responses); - // } - // return state.value; + // return executeRequestGenerators(requestGenerators, requests => this.client.batchRequests(requests).responses); // } // @sync-only-end diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 4ec27f4805f98..f5916b651031c 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -237,11 +237,11 @@ export interface TranspileOutput { sourceMapText?: string; } -export { all } from "./generatorSupport.ts"; +export { all, defer } from "./generatorSupport.ts"; import { - all, type APIRequestGenerator, type ExecutedGeneratorsResults, + executeRequestGenerators, } from "./generatorSupport.ts"; export class API implements FormatDiagnosticsHost { @@ -288,12 +288,7 @@ export class API implements FormatDiagnosticsHo } batch(...requestGenerators: T): ExecutedGeneratorsResults { - const batches = all(...requestGenerators); - let state = batches.next(); - while (!state.done) { - state = batches.next(this.client.batchRequests(state.value).responses); - } - return state.value; + return executeRequestGenerators(requestGenerators, requests => this.client.batchRequests(requests).responses); } private get ensureInitialized(): { diff --git a/packages/typescript/src/api/sync/generatorSupport.ts b/packages/typescript/src/api/sync/generatorSupport.ts index 97f89abcaafa7..f60220cfd871c 100644 --- a/packages/typescript/src/api/sync/generatorSupport.ts +++ b/packages/typescript/src/api/sync/generatorSupport.ts @@ -22,82 +22,145 @@ export function apiRequest( export function* apiRequest(method: PropertyKey, params: unknown): Generator<{ method: PropertyKey; params: unknown; }, unknown, unknown> { return yield { method, params }; } -export type APIRequestGenerator = Generator; +const deferredGeneratorMarker: unique symbol = Symbol(); +interface DeferredAPIRequest { + readonly deferred: UndeferredAPIRequestGenerator; +} +type APIRequestGeneratorYield = APIRequest | readonly APIRequest[] | DeferredAPIRequest; +export type UndeferredAPIRequestGenerator = Generator; +export type DeferredAPIRequestGenerator = Generator & { readonly [deferredGeneratorMarker]: true; }; +export type APIRequestGenerator = UndeferredAPIRequestGenerator | DeferredAPIRequestGenerator; type GeneratorReturn = T extends Generator ? R : never; -export type ExecutedGeneratorsResults = { - [K in keyof T]: GeneratorReturn; -}; +export type ExecutedGeneratorsResults = number extends T["length"] ? GeneratorReturn>[] + : T extends readonly [infer Head extends APIRequestGenerator, ...infer Tail extends readonly APIRequestGenerator[]] ? Head extends DeferredAPIRequestGenerator ? ExecutedGeneratorsResults : [GeneratorReturn, ...ExecutedGeneratorsResults] + : []; interface GeneratorResponse { result: unknown; error?: string | undefined; } -export function all( - ...requestGenerators: T -): Generator, readonly GeneratorResponse[]>; -export function* all( - ...requestGenerators: T -): Generator, readonly GeneratorResponse[]> { - const results: any[] = []; - const requestObjects: (APIRequest | readonly APIRequest[] | undefined)[] = []; - const completedIndices = new Set(); - for (let i = 0; i < requestGenerators.length; i++) { - const state = requestGenerators[i].next(); +interface RequestRunnerOptions { + executeDeferred?: boolean; + getDeduplicationKey?: (request: APIRequest) => string | undefined; +} + +function createRequestRunner(requestGenerators: T, options: RequestRunnerOptions = {}) { + const registeredGenerators = new Set(); + const requestsByGenerator = new Map(); + const resultsByGenerator = new Map(); + for (const generator of requestGenerators) { + addGenerator(generator); + } + const requestRounds = runRequestRounds(); + return { requestRounds, getResults }; + + function advanceGenerator(generator: APIRequestGenerator, value?: unknown, error?: string): void { + let state = error === undefined + ? generator.next(value) + : generator.throw(new Error(error)); + while (!state.done && isDeferredAPIRequest(state.value) && options.executeDeferred) { + addGenerator(state.value.deferred); + state = generator.next(); + } if (state.done) { - results[i] = state.value; - completedIndices.add(i); + requestsByGenerator.delete(generator); + resultsByGenerator.set(generator, state.value); } else { - requestObjects[i] = state.value; + requestsByGenerator.set(generator, state.value); } } - while (completedIndices.size < requestGenerators.length) { - const requests: APIRequest[] = []; - const responseIndices: (number | readonly number[] | undefined)[] = []; - const responseIndexByDeduplicationKey = new Map(); - const addRequest = (request: APIRequest): number => { - const deduplicationKey = getRequestDeduplicationKey(request); - let responseIndex = deduplicationKey === undefined ? undefined : responseIndexByDeduplicationKey.get(deduplicationKey); - if (responseIndex === undefined) { - responseIndex = requests.length; - requests.push(request); - if (deduplicationKey !== undefined) responseIndexByDeduplicationKey.set(deduplicationKey, responseIndex); - } - return responseIndex; - }; - for (let i = 0; i < requestGenerators.length; i++) { - if (completedIndices.has(i)) continue; - const request = requestObjects[i]!; - responseIndices[i] = isRequestGroup(request) ? request.map(addRequest) : addRequest(request); - } + function addGenerator(generator: APIRequestGenerator): void { + if (registeredGenerators.has(generator)) throw new Error("Cannot execute the same generator instance more than once"); + registeredGenerators.add(generator); + advanceGenerator(generator); + } - const responses = yield requests; - for (let i = 0; i < requestGenerators.length; i++) { - if (completedIndices.has(i)) continue; - - const requestGenerator = requestGenerators[i]; - const responseIndex = responseIndices[i]!; - let state: IteratorResult; - if (typeof responseIndex === "number") { - const result = responses[responseIndex]; - state = result.error - ? requestGenerator.throw(new Error(result.error)) - : requestGenerator.next(result.result); + function* runRequestRounds(): Generator { + while (requestsByGenerator.size) { + for (const generator of registeredGenerators) { + let request = requestsByGenerator.get(generator); + while (request && isDeferredAPIRequest(request)) { + yield request; + advanceGenerator(generator); + request = requestsByGenerator.get(generator); + } } - else { - state = requestGenerator.next(responseIndex.map(index => responses[index])); + if (!requestsByGenerator.size) break; + + const requests: APIRequest[] = []; + const responseIndexByDeduplicationKey = new Map(); + const addRequest = (request: APIRequest): number => { + const deduplicationKey = options.getDeduplicationKey?.(request); + let responseIndex = deduplicationKey === undefined ? undefined : responseIndexByDeduplicationKey.get(deduplicationKey); + if (responseIndex === undefined) { + responseIndex = requests.length; + requests.push(request); + if (deduplicationKey !== undefined) responseIndexByDeduplicationKey.set(deduplicationKey, responseIndex); + } + return responseIndex; + }; + // TODO: Use Iterator.prototype.filter when target >= ES2025 + const roundGenerators = [...registeredGenerators].filter(generator => requestsByGenerator.has(generator)); + const responseIndices = new Map(); + for (const generator of roundGenerators) { + const request = requestsByGenerator.get(generator) as APIRequest | readonly APIRequest[]; + responseIndices.set(generator, isRequestGroup(request) ? request.map(addRequest) : addRequest(request)); } - if (state.done) { - results[i] = state.value; - completedIndices.add(i); - requestObjects[i] = undefined; + + const responses = yield requests; + for (const generator of roundGenerators) { + const responseIndex = responseIndices.get(generator)!; + if (typeof responseIndex === "number") { + const result = responses[responseIndex]; + advanceGenerator(generator, result.result, result.error || undefined); + } + else { + advanceGenerator(generator, responseIndex.map(index => responses[index])); + } } - else requestObjects[i] = state.value; } } - return results as any[] as ExecutedGeneratorsResults; + + function getResults(): ExecutedGeneratorsResults { + return requestGenerators + .filter(generator => !isDeferredGenerator(generator)) + .map(generator => resultsByGenerator.get(generator)) as ExecutedGeneratorsResults; + } +} + +export function all( + ...requestGenerators: T +): UndeferredAPIRequestGenerator>; +export function* all( + ...requestGenerators: T +): UndeferredAPIRequestGenerator> { + const { requestRounds, getResults } = createRequestRunner(requestGenerators); + yield* requestRounds; + return getResults(); +} + +export function executeRequestGenerators( + requestGenerators: T, + executeRequests: (requests: APIRequest[]) => readonly GeneratorResponse[], +): ExecutedGeneratorsResults { + const { requestRounds, getResults } = createRequestRunner(requestGenerators, { executeDeferred: true, getDeduplicationKey: getRequestDeduplicationKey }); + let state = requestRounds.next(); + while (!state.done) { + if (isDeferredAPIRequest(state.value)) throw new Error("Unexpected deferred request"); + state = requestRounds.next(executeRequests(state.value)); + } + return getResults(); +} + +function isDeferredAPIRequest(request: APIRequestGeneratorYield): request is DeferredAPIRequest { + return !Array.isArray(request) && "deferred" in request; +} + +function isDeferredGenerator(generator: APIRequestGenerator): generator is DeferredAPIRequestGenerator { + return deferredGeneratorMarker in generator; } function isRequestGroup(request: APIRequest | readonly APIRequest[]): request is readonly APIRequest[] { @@ -112,3 +175,11 @@ function getRequestDeduplicationKey(request: APIRequest): string | undefined { return undefined; } } + +export function defer(gen: UndeferredAPIRequestGenerator): DeferredAPIRequestGenerator { + const deferred = (function* (): Generator { + yield { deferred: gen }; + })() as DeferredAPIRequestGenerator; + Object.defineProperty(deferred, deferredGeneratorMarker, { value: true }); + return deferred; +} diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 546bc5a0d38fc..fb6aa3ad3d0e9 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -28,6 +28,7 @@ import { all, type API, type ConditionalType, + defer, type IndexedAccessType, type IndexInfo, type InterfaceType, @@ -54,6 +55,7 @@ import { describe, test, } from "node:test"; +import { executeRequestGenerators } from "../../src/api/sync/generatorSupport.ts"; import { spawnAPI } from "./api.testUtils.ts"; const parityFiles = { @@ -364,12 +366,16 @@ function runParityBatch(api: API, cases: readonly ParityCase[]): void { } function* observeRequestBatches( - requestGenerator: Generator, + requestGenerator: Generator, requestBatches: string[][], -): Generator { +): Generator { let state = requestGenerator.next(); while (!state.done) { const request = state.value; + if (!Array.isArray(request) && "deferred" in request) { + state = requestGenerator.next(yield request); + continue; + } const requests = Array.isArray(request) ? request : [request as APIRequest]; requestBatches.push(requests.map(current => current.method)); state = requestGenerator.next(yield request); @@ -417,7 +423,7 @@ describe("API - generator batching", () => { assert.deepEqual([...config.fileNames].sort(), ["/src/bind.ts", "/src/index.ts", "/src/models.ts", "/src/suggestions.ts", "/src/syntax.ts"]); assert.match(outputText, /const value = ['"]ok['"]/); assert.deepEqual(requestBatches, [ - ["initialize"], + ["initialize", "initialize"], ["parseCommandLine", "readConfigFile"], ["readConfigFile", "parseJsonConfigFileContent"], ["parseJsonConfigFileContent", "transpileModule"], @@ -489,33 +495,301 @@ describe("API - generator batching", () => { } }); - test("all deduplicates only initialize requests within a batch round", () => { + test("executes deferred generators without returning their results", () => { const api = spawnAPI(); const requestBatches: string[][] = []; + function* runConcurrentRequests() { + yield* defer(api.parseCommandLine.gen(["--strict"])); + yield* defer(api.readConfigFile.gen("/tsconfig.json")); + return yield* all( + api.parseCommandLine.gen(["--target", "esnext"]), + api.readConfigFile.gen("/base.json"), + ); + } try { - const [[firstCommandLine, secondCommandLine, config]] = api.batch(observeRequestBatches( - all( - api.parseCommandLine.gen(["--strict"]), - api.parseCommandLine.gen(["--strict"]), - api.readConfigFile.gen("/tsconfig.json"), - ), - requestBatches, - )); - + const [[[commandLine, config]]] = api.batch(observeRequestBatches(all(runConcurrentRequests()), requestBatches)); + assert.equal(commandLine.options.target, 99); + assert.deepEqual(config.config, {}); assert.deepEqual(requestBatches, [ - ["initialize"], - ["parseCommandLine", "parseCommandLine", "readConfigFile"], + ["initialize", "initialize"], + ["parseCommandLine", "readConfigFile"], ]); - assert.equal(firstCommandLine.options.strict, true); - assert.equal(secondCommandLine.options.strict, true); + + const [onlyConfig] = api.batch( + defer(api.parseCommandLine.gen(["--strict"])), + api.readConfigFile.gen("/tsconfig.json"), + ); + assert.deepEqual(onlyConfig.config, {}); + } + finally { + api.close(); + } + }); + + test("waits for deferred work spawned after a request", () => { + const api = spawnAPI(); + const events: string[] = []; + function* backgroundWork() { + events.push("started"); + yield* api.parseCommandLine.gen(["--strict"]); + events.push("completed"); + } + function* foregroundWork() { + const config = yield* api.readConfigFile.gen("/tsconfig.json"); + yield* defer(backgroundWork()); + events.push("foreground completed"); + return config; + } + + try { + const [config] = api.batch(foregroundWork()); + assert.deepEqual(config.config, {}); + assert.deepEqual(events, ["started", "foreground completed", "completed"]); + } + finally { + api.close(); + } + }); + + test("does not block on deferred work from a nested all", () => { + const api = spawnAPI(); + const events: string[] = []; + function* backgroundWork() { + yield* api.readConfigFile.gen("/base.json"); + yield* api.readConfigFile.gen("/tsconfig.json"); + yield* api.readConfigFile.gen("/base.json"); + events.push("background completed"); + } + function* nestedWork() { + yield* defer(backgroundWork()); + return yield* api.parseCommandLine.gen(["--strict"]); + } + function* foregroundWork() { + const [commandLine] = yield* all(nestedWork()); + events.push("nested all completed"); + const config = yield* api.readConfigFile.gen("/tsconfig.json"); + events.push("foreground completed"); + return { commandLine, config }; + } + + try { + api.parseCommandLine([]); + const [{ commandLine, config }] = api.batch(foregroundWork()); + assert.equal(commandLine.options.strict, true); + assert.deepEqual(config.config, {}); + assert.deepEqual(events, ["nested all completed", "foreground completed", "background completed"]); + } + finally { + api.close(); + } + }); + + test("omits deferred results in any batch position", () => { + const api = spawnAPI(); + let synchronousWorkCompleted = false; + function* synchronousWork() { + synchronousWorkCompleted = true; + return "ignored"; + } + + try { + const [commandLine, config] = api.batch( + api.parseCommandLine.gen(["--strict"]), + defer(api.readConfigFile.gen("/base.json")), + api.readConfigFile.gen("/tsconfig.json"), + defer(synchronousWork()), + ); + assert.equal(commandLine.options.strict, true); assert.deepEqual(config.config, {}); + assert.equal(synchronousWorkCompleted, true); + } + finally { + api.close(); + } + }); + + test("throws request errors into deferred generators", () => { + const api = spawnAPI(); + const events: string[] = []; + function* requestWithCleanup(): Generator { + try { + yield { method: "unknown", params: null } as unknown as APIRequest; + } + catch { + events.push("caught"); + } + finally { + events.push("finally"); + } + } + + try { + assert.deepEqual(api.batch(defer(requestWithCleanup())), []); + assert.deepEqual(events, ["caught", "finally"]); } finally { api.close(); } }); + test("batch execution deduplicates only initialize requests within a round", () => { + const requestBatches: string[][] = []; + function* request(method: string): Generator { + return yield { method, params: null } as unknown as APIRequest; + } + + const results = executeRequestGenerators( + [request("initialize"), request("initialize"), request("other"), request("other")], + requests => { + requestBatches.push(requests.map(request => request.method)); + return requests.map(request => ({ result: request.method })); + }, + ); + + assert.deepEqual(requestBatches, [["initialize", "other", "other"]]); + assert.deepEqual(results, ["initialize", "initialize", "other", "other"]); + }); + + test("does not execute an empty request round", () => { + let executions = 0; + function* completed(value: string) { + return value; + } + + assert.deepEqual( + executeRequestGenerators([], () => { + executions++; + return []; + }), + [], + ); + assert.deepEqual( + executeRequestGenerators([completed("first"), completed("second")], () => { + executions++; + return []; + }), + ["first", "second"], + ); + assert.equal(executions, 0); + }); + + test("maps deduplicated responses back into request groups", () => { + const requestBatches: string[][] = []; + function* groupedRequests(): Generator { + const responses = yield [ + { method: "initialize", params: null }, + { method: "other", params: null }, + { method: "initialize", params: null }, + ] as unknown as readonly APIRequest[]; + return responses.map(response => response.result); + } + function* initializeRequest(): Generator { + return yield { method: "initialize", params: null } as unknown as APIRequest; + } + + const results = executeRequestGenerators([groupedRequests(), initializeRequest()], requests => { + requestBatches.push(requests.map(request => request.method)); + return requests.map((request, index) => ({ result: `${request.method}:${index}` })); + }); + + assert.deepEqual(requestBatches, [["initialize", "other"]]); + assert.deepEqual(results, [["initialize:0", "other:1", "initialize:0"], "initialize:0"]); + }); + + test("isolates handled errors from other generators in the same round", () => { + const requestBatches: string[][] = []; + function* recoveringRequest(): Generator { + try { + yield { method: "bad", params: null } as unknown as APIRequest; + } + catch { + return yield { method: "recovery", params: null } as unknown as APIRequest; + } + return "unexpected"; + } + function* successfulRequest(): Generator { + return yield { method: "good", params: null } as unknown as APIRequest; + } + + const results = executeRequestGenerators([recoveringRequest(), successfulRequest()], requests => { + requestBatches.push(requests.map(request => request.method)); + return requests.map(request => request.method as string === "bad" ? { result: undefined, error: "failed" } : { result: request.method }); + }); + + assert.deepEqual(requestBatches, [["bad", "good"], ["recovery"]]); + assert.deepEqual(results, ["recovery", "good"]); + }); + + test("executes recursively deferred generators", () => { + const events: string[] = []; + function* grandchild() { + yield { method: "grandchild", params: null } as unknown as APIRequest; + events.push("grandchild completed"); + } + function* child() { + yield* defer(grandchild()); + yield { method: "child", params: null } as unknown as APIRequest; + events.push("child completed"); + } + function* parent() { + yield* defer(child()); + yield { method: "parent", params: null } as unknown as APIRequest; + events.push("parent completed"); + } + + executeRequestGenerators([parent()], requests => requests.map(request => ({ result: request.method }))); + + assert.deepEqual(events, ["parent completed", "child completed", "grandchild completed"]); + }); + + test("surfaces deferred errors after the parent completes", () => { + const events: string[] = []; + function* background() { + yield { method: "background", params: null } as unknown as APIRequest; + } + function* parent() { + yield* defer(background()); + events.push("parent completed"); + } + + assert.throws( + () => executeRequestGenerators([parent()], requests => requests.map(() => ({ result: undefined, error: "deferred failure" }))), + /deferred failure/, + ); + assert.deepEqual(events, ["parent completed"]); + }); + + test("appends deferred work after the parent request", () => { + const requestBatches: string[][] = []; + function* background() { + yield { method: "background", params: null } as unknown as APIRequest; + } + function* parent() { + yield* defer(background()); + yield { method: "parent", params: null } as unknown as APIRequest; + } + + executeRequestGenerators([parent()], requests => { + requestBatches.push(requests.map(request => request.method)); + return requests.map(request => ({ result: request.method })); + }); + + assert.deepEqual(requestBatches, [["parent", "background"]]); + }); + + test("rejects repeated generator instances", () => { + function* request(): Generator { + return yield { method: "request", params: null } as unknown as APIRequest; + } + const generator = request(); + + assert.throws( + () => executeRequestGenerators([generator, generator], requests => requests.map(request => ({ result: request.method }))), + /same generator instance more than once/, + ); + }); + test("yields source file metadata requests on cache misses", () => { const api = spawnAPI(); try {