From 57c0a93d8af4a28abc4ab7eabc99e6be8f1a8840 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:26:37 +0000 Subject: [PATCH 1/4] Initial plan From bbfef614b64a83311dd9e8f8e4c16ece2cb26892 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:37:04 +0000 Subject: [PATCH 2/4] feat: add AbortSignal cancellation support for source.gather - Add `signal?: AbortSignal` to `GatherArguments` in base/source.ts (backward compatible) - Modify `callSourceGather` in ext.ts to accept signal and use Promise.race with an abort promise; the abort error has name='DdcCallbackCancelError' so the existing isDdcCallbackCancelError guard silently discards it - Add `#currentGatherController` field and `abortCurrentGather()` to Ddc class - `doCompletion` now creates a new AbortController on each call and aborts the previous one, cancelling in-flight gathers immediately - `_onEvent` in app.ts calls `ddc.abortCurrentGather()` alongside `cbContext.revoke()` so rapid input cancels previous gathers at event time - Add unit tests in tests/gather_cancel_test.ts covering both signal and no-signal paths Agent-Logs-Url: https://github.com/Shougo/ddc.vim/sessions/afa28fc1-f849-4683-b8bd-fd0866840455 Co-authored-by: Shougo <41495+Shougo@users.noreply.github.com> --- denops/ddc/app.ts | 2 + denops/ddc/base/source.ts | 1 + denops/ddc/ddc.ts | 18 ++++ denops/ddc/ext.ts | 33 ++++++- denops/ddc/tests/gather_cancel_test.ts | 120 +++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 denops/ddc/tests/gather_cancel_test.ts 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..d5b0ebb 100644 --- a/denops/ddc/ext.ts +++ b/denops/ddc/ext.ts @@ -810,6 +810,7 @@ export async function callSourceGather< completePos: number, completeStr: string, isIncomplete: boolean, + signal?: AbortSignal, ): Promise> { try { const args = { @@ -823,15 +824,43 @@ 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). + const abortPromise = new Promise((_res, rej) => { + if (signal.aborted) { + const e = new Error("gather aborted"); + (e as { name: string }).name = "DdcCallbackCancelError"; + rej(e); + return; + } + signal.addEventListener( + "abort", + () => { + const e = new Error("gather aborted"); + (e as { name: string }).name = "DdcCallbackCancelError"; + rej(e); + }, + { once: true }, + ); + }); + + return await Promise.race([gatherPromise, abortPromise]); } 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..7be0c25 --- /dev/null +++ b/denops/ddc/tests/gather_cancel_test.ts @@ -0,0 +1,120 @@ +/** + * 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"; + +// --------------------------------------------------------------------------- +// Helper: re-creates the abort-promise logic from callSourceGather so that we +// can test it in isolation. +// --------------------------------------------------------------------------- +function createAbortPromise(signal: AbortSignal): Promise { + return new Promise((_res, rej) => { + if (signal.aborted) { + const e = new Error("gather aborted"); + (e as { name: string }).name = "DdcCallbackCancelError"; + rej(e); + return; + } + signal.addEventListener( + "abort", + () => { + const e = new Error("gather aborted"); + (e as { name: string }).name = "DdcCallbackCancelError"; + rej(e); + }, + { once: true }, + ); + }); +} + +// --------------------------------------------------------------------------- +// 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((_resolve) => { + // 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"]); +}); From ef0bad00b5c3bd2cffe7ad94f09161b1e574eba3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:39:11 +0000 Subject: [PATCH 3/4] refactor: extract createGatherAbortError and createAbortPromise helpers - Add exported createGatherAbortError() to build a DdcCallbackCancelError-named Error - Add exported createAbortPromise() using the helper, eliminating duplication - Update gather_cancel_test.ts to import createAbortPromise from ext.ts instead of duplicating the logic Agent-Logs-Url: https://github.com/Shougo/ddc.vim/sessions/afa28fc1-f849-4683-b8bd-fd0866840455 Co-authored-by: Shougo <41495+Shougo@users.noreply.github.com> --- denops/ddc/ext.ts | 49 ++++++++++++++++---------- denops/ddc/tests/gather_cancel_test.ts | 25 +------------ 2 files changed, 31 insertions(+), 43 deletions(-) diff --git a/denops/ddc/ext.ts b/denops/ddc/ext.ts index d5b0ebb..c8c994f 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((_res, rej) => { + if (signal.aborted) { + rej(createGatherAbortError()); + return; + } + signal.addEventListener( + "abort", + () => rej(createGatherAbortError()), + { once: true }, + ); + }); +} + export async function callSourceGather< Params extends BaseParams, UserData extends unknown, @@ -836,25 +865,7 @@ export async function callSourceGather< // 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). - const abortPromise = new Promise((_res, rej) => { - if (signal.aborted) { - const e = new Error("gather aborted"); - (e as { name: string }).name = "DdcCallbackCancelError"; - rej(e); - return; - } - signal.addEventListener( - "abort", - () => { - const e = new Error("gather aborted"); - (e as { name: string }).name = "DdcCallbackCancelError"; - rej(e); - }, - { once: true }, - ); - }); - - return await Promise.race([gatherPromise, abortPromise]); + return await Promise.race([gatherPromise, createAbortPromise(signal)]); } catch (e: unknown) { if ( isDdcCallbackCancelError(e) || diff --git a/denops/ddc/tests/gather_cancel_test.ts b/denops/ddc/tests/gather_cancel_test.ts index 7be0c25..b5c3867 100644 --- a/denops/ddc/tests/gather_cancel_test.ts +++ b/denops/ddc/tests/gather_cancel_test.ts @@ -7,30 +7,7 @@ import { assertEquals } from "@std/assert/equals"; import { isDdcCallbackCancelError } from "../callback.ts"; - -// --------------------------------------------------------------------------- -// Helper: re-creates the abort-promise logic from callSourceGather so that we -// can test it in isolation. -// --------------------------------------------------------------------------- -function createAbortPromise(signal: AbortSignal): Promise { - return new Promise((_res, rej) => { - if (signal.aborted) { - const e = new Error("gather aborted"); - (e as { name: string }).name = "DdcCallbackCancelError"; - rej(e); - return; - } - signal.addEventListener( - "abort", - () => { - const e = new Error("gather aborted"); - (e as { name: string }).name = "DdcCallbackCancelError"; - rej(e); - }, - { once: true }, - ); - }); -} +import { createAbortPromise } from "../ext.ts"; // --------------------------------------------------------------------------- // Test: an already-aborted signal causes immediate rejection with the right From 9848b1ab79d08ad1c74bff2e52009710cd028ba3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:40:21 +0000 Subject: [PATCH 4/4] style: remove unused Promise resolve parameters - Use `(_, rej)` in createAbortPromise instead of `(_res, rej)` - Use `() => {}` in test instead of `(_resolve) => {}` Agent-Logs-Url: https://github.com/Shougo/ddc.vim/sessions/afa28fc1-f849-4683-b8bd-fd0866840455 Co-authored-by: Shougo <41495+Shougo@users.noreply.github.com> --- denops/ddc/ext.ts | 2 +- denops/ddc/tests/gather_cancel_test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/denops/ddc/ext.ts b/denops/ddc/ext.ts index c8c994f..15442ea 100644 --- a/denops/ddc/ext.ts +++ b/denops/ddc/ext.ts @@ -811,7 +811,7 @@ export function createGatherAbortError(): Error { * as soon as the given AbortSignal is (or becomes) aborted. */ export function createAbortPromise(signal: AbortSignal): Promise { - return new Promise((_res, rej) => { + return new Promise((_, rej) => { if (signal.aborted) { rej(createGatherAbortError()); return; diff --git a/denops/ddc/tests/gather_cancel_test.ts b/denops/ddc/tests/gather_cancel_test.ts index b5c3867..028b432 100644 --- a/denops/ddc/tests/gather_cancel_test.ts +++ b/denops/ddc/tests/gather_cancel_test.ts @@ -40,7 +40,7 @@ Deno.test("gather cancel: aborting mid-flight cancels the gather via Promise.rac const controller = new AbortController(); // Simulate a slow gather that never completes on its own. - const slowGather = new Promise((_resolve) => { + const slowGather = new Promise(() => { // intentionally never resolves });