diff --git a/denops/ddc/app.ts b/denops/ddc/app.ts index e14d34c..465ffea 100644 --- a/denops/ddc/app.ts +++ b/denops/ddc/app.ts @@ -378,6 +378,8 @@ export const main: Entrypoint = (denops: Denops) => { // Revoke any pending callbacks from the previous completion cycle before // starting a new one. cbContext.revoke(); + // Abort any in-flight gather from the previous completion cycle. + ddc.abortCurrentGather(); await onEvent( denops, diff --git a/denops/ddc/base/source.ts b/denops/ddc/base/source.ts index 25dd596..76f8621 100644 --- a/denops/ddc/base/source.ts +++ b/denops/ddc/base/source.ts @@ -64,6 +64,7 @@ export type GatherArguments = completePos: number; completeStr: string; isIncomplete?: boolean; + signal?: AbortSignal; }; export abstract class BaseSource< diff --git a/denops/ddc/ddc.ts b/denops/ddc/ddc.ts index e3086e6..452fa4c 100644 --- a/denops/ddc/ddc.ts +++ b/denops/ddc/ddc.ts @@ -61,11 +61,21 @@ export class Ddc { #prevUi = ""; #prevEvent = ""; #state: State | undefined; + #currentGatherController: AbortController | undefined = undefined; constructor(loader: Loader) { this.#loader = loader; } + /** + * Abort any in-flight gather started by the most recent doCompletion call. + * Called alongside cbContext.revoke() so that a new input event cancels + * the previous gather immediately. + */ + abortCurrentGather(): void { + this.#currentGatherController?.abort(); + } + initialize(denops: Denops) { this.#state = new State(denops); this.#state.set("ddc#_changedtick", 0); @@ -194,6 +204,7 @@ export class Ddc { context: Context, onCallback: OnCallback, options: DdcOptions, + signal?: AbortSignal, ): Promise<[number, DdcItem[]]> { this.#prevSources = options.sources; @@ -308,6 +319,7 @@ export class Ddc { ? completeStr.replace(replacePattern, "") : completeStr, triggerForIncomplete, + signal, ); const timeoutPromise = new Promise( @@ -577,11 +589,17 @@ export class Ddc { cbContext: CallbackContext, options: DdcOptions, ) { + // Cancel the previous in-flight gather and start a fresh one. + this.#currentGatherController?.abort(); + const controller = new AbortController(); + this.#currentGatherController = controller; + const [completePos, items] = await this.gatherResults( denops, context, cbContext.createOnCallback(), options, + controller.signal, ); this.#prevInput = context.input; diff --git a/denops/ddc/ext.ts b/denops/ddc/ext.ts index 9ee6f73..15442ea 100644 --- a/denops/ddc/ext.ts +++ b/denops/ddc/ext.ts @@ -795,6 +795,35 @@ export async function callSourceGetCompletePosition( } } +/** + * Build an Error that is recognised by isDdcCallbackCancelError. + * Used when an AbortSignal fires so the existing error-guard in + * callSourceGather silently discards the abort rather than logging it. + */ +export function createGatherAbortError(): Error { + const e = new Error("gather aborted"); + (e as { name: string }).name = "DdcCallbackCancelError"; + return e; +} + +/** + * Returns a Promise that rejects with a DdcCallbackCancelError-named error + * as soon as the given AbortSignal is (or becomes) aborted. + */ +export function createAbortPromise(signal: AbortSignal): Promise { + return new Promise((_, rej) => { + if (signal.aborted) { + rej(createGatherAbortError()); + return; + } + signal.addEventListener( + "abort", + () => rej(createGatherAbortError()), + { once: true }, + ); + }); +} + export async function callSourceGather< Params extends BaseParams, UserData extends unknown, @@ -810,6 +839,7 @@ export async function callSourceGather< completePos: number, completeStr: string, isIncomplete: boolean, + signal?: AbortSignal, ): Promise> { try { const args = { @@ -823,15 +853,25 @@ export async function callSourceGather< completePos, completeStr, isIncomplete, + signal, }; - return await deadline(source.gather(args), sourceOptions.timeout); + const gatherPromise = deadline(source.gather(args), sourceOptions.timeout); + + if (!signal) { + return await gatherPromise; + } + + // Race the gather against an abort promise so that when the signal fires + // the gather is abandoned immediately (even if the source does not check + // the signal itself). + return await Promise.race([gatherPromise, createAbortPromise(signal)]); } catch (e: unknown) { if ( isDdcCallbackCancelError(e) || e instanceof DOMException ) { - // Ignore timeout error + // Ignore abort/timeout error } else { await printError( denops, diff --git a/denops/ddc/tests/gather_cancel_test.ts b/denops/ddc/tests/gather_cancel_test.ts new file mode 100644 index 0000000..028b432 --- /dev/null +++ b/denops/ddc/tests/gather_cancel_test.ts @@ -0,0 +1,97 @@ +/** + * Unit tests for gather AbortSignal cancellation. + * + * These tests exercise the abort-promise logic used inside callSourceGather + * without requiring a live Denops instance. + */ + +import { assertEquals } from "@std/assert/equals"; +import { isDdcCallbackCancelError } from "../callback.ts"; +import { createAbortPromise } from "../ext.ts"; + +// --------------------------------------------------------------------------- +// Test: an already-aborted signal causes immediate rejection with the right +// error name so that isDdcCallbackCancelError recognises it. +// --------------------------------------------------------------------------- +Deno.test("gather cancel: already-aborted signal rejects with DdcCallbackCancelError", async () => { + const controller = new AbortController(); + controller.abort(); + + let caught: unknown; + try { + await createAbortPromise(controller.signal); + } catch (e) { + caught = e; + } + + assertEquals(caught instanceof Error, true, "should throw an Error"); + assertEquals( + isDdcCallbackCancelError(caught), + true, + "error must satisfy isDdcCallbackCancelError", + ); +}); + +// --------------------------------------------------------------------------- +// Test: aborting a signal after Promise.race starts cancels a never-resolving +// gather and the result is [] (simulating callSourceGather error handling). +// --------------------------------------------------------------------------- +Deno.test("gather cancel: aborting mid-flight cancels the gather via Promise.race", async () => { + const controller = new AbortController(); + + // Simulate a slow gather that never completes on its own. + const slowGather = new Promise(() => { + // intentionally never resolves + }); + + const racePromise = Promise.race([ + slowGather, + createAbortPromise(controller.signal), + ]); + + // Abort after a microtask to let the race settle. + controller.abort(); + + let caught: unknown; + try { + await racePromise; + } catch (e) { + caught = e; + } + + assertEquals(caught instanceof Error, true, "should throw an Error"); + assertEquals( + isDdcCallbackCancelError(caught), + true, + "error must satisfy isDdcCallbackCancelError", + ); +}); + +// --------------------------------------------------------------------------- +// Test: without a signal the gather completes normally (legacy path). +// --------------------------------------------------------------------------- +Deno.test("gather cancel: no signal – gather resolves normally", async () => { + // When callSourceGather receives no signal it just awaits the gather promise. + const fastGather = Promise.resolve(["item1", "item2"]); + + // No signal → just await directly (the `if (!signal) return await gather` + // path). Simulate that here. + const result = await fastGather; + + assertEquals(result, ["item1", "item2"]); +}); + +// --------------------------------------------------------------------------- +// Test: a signal that is never aborted does not interfere with normal +// completion. +// --------------------------------------------------------------------------- +Deno.test("gather cancel: non-aborted signal – gather resolves normally", async () => { + const controller = new AbortController(); + + const fastGather = Promise.resolve(["item1"]); + const abortPromise = createAbortPromise(controller.signal); + + const result = await Promise.race([fastGather, abortPromise]); + + assertEquals(result, ["item1"]); +});