Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions denops/ddc/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions denops/ddc/base/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export type GatherArguments<Params extends BaseParams> =
completePos: number;
completeStr: string;
isIncomplete?: boolean;
signal?: AbortSignal;
};

export abstract class BaseSource<
Expand Down
18 changes: 18 additions & 0 deletions denops/ddc/ddc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -194,6 +204,7 @@ export class Ddc {
context: Context,
onCallback: OnCallback,
options: DdcOptions,
signal?: AbortSignal,
): Promise<[number, DdcItem[]]> {
this.#prevSources = options.sources;

Expand Down Expand Up @@ -308,6 +319,7 @@ export class Ddc {
? completeStr.replace(replacePattern, "")
: completeStr,
triggerForIncomplete,
signal,
);

const timeoutPromise = new Promise(
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 42 additions & 2 deletions denops/ddc/ext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never> {
return new Promise<never>((_, rej) => {
if (signal.aborted) {
rej(createGatherAbortError());
return;
}
signal.addEventListener(
"abort",
() => rej(createGatherAbortError()),
{ once: true },
);
});
}

export async function callSourceGather<
Params extends BaseParams,
UserData extends unknown,
Expand All @@ -810,6 +839,7 @@ export async function callSourceGather<
completePos: number,
completeStr: string,
isIncomplete: boolean,
signal?: AbortSignal,
): Promise<DdcGatherItems<UserData>> {
try {
const args = {
Expand All @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions denops/ddc/tests/gather_cancel_test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>(() => {
// 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"]);
});
Loading