Skip to content

Commit a0aa281

Browse files
authored
Merge pull request #176 from Shougo/copilot/add-abort-signal-to-gather
feat: cancel in-flight source.gather on new input events via AbortSignal
2 parents e0e80ea + 9848b1a commit a0aa281

5 files changed

Lines changed: 160 additions & 2 deletions

File tree

denops/ddc/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,8 @@ export const main: Entrypoint = (denops: Denops) => {
378378
// Revoke any pending callbacks from the previous completion cycle before
379379
// starting a new one.
380380
cbContext.revoke();
381+
// Abort any in-flight gather from the previous completion cycle.
382+
ddc.abortCurrentGather();
381383

382384
await onEvent(
383385
denops,

denops/ddc/base/source.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export type GatherArguments<Params extends BaseParams> =
6464
completePos: number;
6565
completeStr: string;
6666
isIncomplete?: boolean;
67+
signal?: AbortSignal;
6768
};
6869

6970
export abstract class BaseSource<

denops/ddc/ddc.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,21 @@ export class Ddc {
6161
#prevUi = "";
6262
#prevEvent = "";
6363
#state: State | undefined;
64+
#currentGatherController: AbortController | undefined = undefined;
6465

6566
constructor(loader: Loader) {
6667
this.#loader = loader;
6768
}
6869

70+
/**
71+
* Abort any in-flight gather started by the most recent doCompletion call.
72+
* Called alongside cbContext.revoke() so that a new input event cancels
73+
* the previous gather immediately.
74+
*/
75+
abortCurrentGather(): void {
76+
this.#currentGatherController?.abort();
77+
}
78+
6979
initialize(denops: Denops) {
7080
this.#state = new State(denops);
7181
this.#state.set("ddc#_changedtick", 0);
@@ -194,6 +204,7 @@ export class Ddc {
194204
context: Context,
195205
onCallback: OnCallback,
196206
options: DdcOptions,
207+
signal?: AbortSignal,
197208
): Promise<[number, DdcItem[]]> {
198209
this.#prevSources = options.sources;
199210

@@ -308,6 +319,7 @@ export class Ddc {
308319
? completeStr.replace(replacePattern, "")
309320
: completeStr,
310321
triggerForIncomplete,
322+
signal,
311323
);
312324

313325
const timeoutPromise = new Promise(
@@ -577,11 +589,17 @@ export class Ddc {
577589
cbContext: CallbackContext,
578590
options: DdcOptions,
579591
) {
592+
// Cancel the previous in-flight gather and start a fresh one.
593+
this.#currentGatherController?.abort();
594+
const controller = new AbortController();
595+
this.#currentGatherController = controller;
596+
580597
const [completePos, items] = await this.gatherResults(
581598
denops,
582599
context,
583600
cbContext.createOnCallback(),
584601
options,
602+
controller.signal,
585603
);
586604

587605
this.#prevInput = context.input;

denops/ddc/ext.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,35 @@ export async function callSourceGetCompletePosition(
795795
}
796796
}
797797

798+
/**
799+
* Build an Error that is recognised by isDdcCallbackCancelError.
800+
* Used when an AbortSignal fires so the existing error-guard in
801+
* callSourceGather silently discards the abort rather than logging it.
802+
*/
803+
export function createGatherAbortError(): Error {
804+
const e = new Error("gather aborted");
805+
(e as { name: string }).name = "DdcCallbackCancelError";
806+
return e;
807+
}
808+
809+
/**
810+
* Returns a Promise that rejects with a DdcCallbackCancelError-named error
811+
* as soon as the given AbortSignal is (or becomes) aborted.
812+
*/
813+
export function createAbortPromise(signal: AbortSignal): Promise<never> {
814+
return new Promise<never>((_, rej) => {
815+
if (signal.aborted) {
816+
rej(createGatherAbortError());
817+
return;
818+
}
819+
signal.addEventListener(
820+
"abort",
821+
() => rej(createGatherAbortError()),
822+
{ once: true },
823+
);
824+
});
825+
}
826+
798827
export async function callSourceGather<
799828
Params extends BaseParams,
800829
UserData extends unknown,
@@ -810,6 +839,7 @@ export async function callSourceGather<
810839
completePos: number,
811840
completeStr: string,
812841
isIncomplete: boolean,
842+
signal?: AbortSignal,
813843
): Promise<DdcGatherItems<UserData>> {
814844
try {
815845
const args = {
@@ -823,15 +853,25 @@ export async function callSourceGather<
823853
completePos,
824854
completeStr,
825855
isIncomplete,
856+
signal,
826857
};
827858

828-
return await deadline(source.gather(args), sourceOptions.timeout);
859+
const gatherPromise = deadline(source.gather(args), sourceOptions.timeout);
860+
861+
if (!signal) {
862+
return await gatherPromise;
863+
}
864+
865+
// Race the gather against an abort promise so that when the signal fires
866+
// the gather is abandoned immediately (even if the source does not check
867+
// the signal itself).
868+
return await Promise.race([gatherPromise, createAbortPromise(signal)]);
829869
} catch (e: unknown) {
830870
if (
831871
isDdcCallbackCancelError(e) ||
832872
e instanceof DOMException
833873
) {
834-
// Ignore timeout error
874+
// Ignore abort/timeout error
835875
} else {
836876
await printError(
837877
denops,
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Unit tests for gather AbortSignal cancellation.
3+
*
4+
* These tests exercise the abort-promise logic used inside callSourceGather
5+
* without requiring a live Denops instance.
6+
*/
7+
8+
import { assertEquals } from "@std/assert/equals";
9+
import { isDdcCallbackCancelError } from "../callback.ts";
10+
import { createAbortPromise } from "../ext.ts";
11+
12+
// ---------------------------------------------------------------------------
13+
// Test: an already-aborted signal causes immediate rejection with the right
14+
// error name so that isDdcCallbackCancelError recognises it.
15+
// ---------------------------------------------------------------------------
16+
Deno.test("gather cancel: already-aborted signal rejects with DdcCallbackCancelError", async () => {
17+
const controller = new AbortController();
18+
controller.abort();
19+
20+
let caught: unknown;
21+
try {
22+
await createAbortPromise(controller.signal);
23+
} catch (e) {
24+
caught = e;
25+
}
26+
27+
assertEquals(caught instanceof Error, true, "should throw an Error");
28+
assertEquals(
29+
isDdcCallbackCancelError(caught),
30+
true,
31+
"error must satisfy isDdcCallbackCancelError",
32+
);
33+
});
34+
35+
// ---------------------------------------------------------------------------
36+
// Test: aborting a signal after Promise.race starts cancels a never-resolving
37+
// gather and the result is [] (simulating callSourceGather error handling).
38+
// ---------------------------------------------------------------------------
39+
Deno.test("gather cancel: aborting mid-flight cancels the gather via Promise.race", async () => {
40+
const controller = new AbortController();
41+
42+
// Simulate a slow gather that never completes on its own.
43+
const slowGather = new Promise<string[]>(() => {
44+
// intentionally never resolves
45+
});
46+
47+
const racePromise = Promise.race([
48+
slowGather,
49+
createAbortPromise(controller.signal),
50+
]);
51+
52+
// Abort after a microtask to let the race settle.
53+
controller.abort();
54+
55+
let caught: unknown;
56+
try {
57+
await racePromise;
58+
} catch (e) {
59+
caught = e;
60+
}
61+
62+
assertEquals(caught instanceof Error, true, "should throw an Error");
63+
assertEquals(
64+
isDdcCallbackCancelError(caught),
65+
true,
66+
"error must satisfy isDdcCallbackCancelError",
67+
);
68+
});
69+
70+
// ---------------------------------------------------------------------------
71+
// Test: without a signal the gather completes normally (legacy path).
72+
// ---------------------------------------------------------------------------
73+
Deno.test("gather cancel: no signal – gather resolves normally", async () => {
74+
// When callSourceGather receives no signal it just awaits the gather promise.
75+
const fastGather = Promise.resolve(["item1", "item2"]);
76+
77+
// No signal → just await directly (the `if (!signal) return await gather`
78+
// path). Simulate that here.
79+
const result = await fastGather;
80+
81+
assertEquals(result, ["item1", "item2"]);
82+
});
83+
84+
// ---------------------------------------------------------------------------
85+
// Test: a signal that is never aborted does not interfere with normal
86+
// completion.
87+
// ---------------------------------------------------------------------------
88+
Deno.test("gather cancel: non-aborted signal – gather resolves normally", async () => {
89+
const controller = new AbortController();
90+
91+
const fastGather = Promise.resolve(["item1"]);
92+
const abortPromise = createAbortPromise(controller.signal);
93+
94+
const result = await Promise.race([fastGather, abortPromise]);
95+
96+
assertEquals(result, ["item1"]);
97+
});

0 commit comments

Comments
 (0)