Skip to content

feat: cancel in-flight source.gather on new input events via AbortSignal#176

Merged
Shougo merged 4 commits intomainfrom
copilot/add-abort-signal-to-gather
Apr 29, 2026
Merged

feat: cancel in-flight source.gather on new input events via AbortSignal#176
Shougo merged 4 commits intomainfrom
copilot/add-abort-signal-to-gather

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 28, 2026

Rapid input causes stale source.gather calls to run to completion unnecessarily, wasting CPU. This adds opt-in AbortSignal support so gathers are abandoned immediately when a new input event arrives.

Changes

denops/ddc/base/source.ts

  • Added signal?: AbortSignal to GatherArguments — optional, fully backward compatible with existing source implementations

denops/ddc/ext.ts

  • createGatherAbortError(): creates an Error with name = 'DdcCallbackCancelError' so the existing error guard silently swallows aborts without logging
  • createAbortPromise(signal): exported helper returning a Promise that rejects when the signal fires
  • callSourceGather: accepts optional signal; when present, wraps the gather in Promise.race([gatherPromise, createAbortPromise(signal)]) — cancels even sources that don't check the signal themselves

denops/ddc/ddc.ts

  • Ddc gains #currentGatherController: AbortController and abortCurrentGather()
  • doCompletion creates a fresh AbortController per call, aborting the previous one before starting
  • gatherResults threads the signal through to callSourceGather

denops/ddc/app.ts

  • ddc.abortCurrentGather() is called alongside the existing cbContext.revoke() in _onEvent, so previous gathers are cancelled at event-arrival time rather than waiting for the next doCompletion

denops/ddc/tests/gather_cancel_test.ts

  • Unit tests covering: already-aborted signal, mid-flight abort via Promise.race, no-signal legacy path, and non-aborted signal resolving normally

Example: source opt-in

// Existing sources work unchanged — signal is simply ignored
async gather(args: GatherArguments<Params>) { ... }

// New sources can check the signal for early exit in hot loops
async gather({ signal, ... }: GatherArguments<Params>) {
  for (const item of largeList) {
    if (signal?.aborted) break;
    // ...
  }
}

Note: Sources with synchronous CPU-bound loops inside an async function can only benefit if they explicitly check signal.aborted. The Promise.race layer only bypasses the await point; it cannot interrupt synchronous execution.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • dl.deno.land
    • Triggering command: /usr/bin/curl curl -s REDACTED (dns block)
  • jsr.io
    • Triggering command: /tmp/deno_bin/deno /tmp/deno_bin/deno test -A denops/ddc/tests/loader_concurrent_test.ts denops/ddc/ddc.ts denops/ddc/callback.ts denops/ddc/utils.ts denops/ddc/context.ts (dns block)
    • Triggering command: /tmp/deno_bin/deno /tmp/deno_bin/deno cache --config deno.json callback.ts (dns block)
    • Triggering command: /tmp/deno_bin/deno /tmp/deno_bin/deno test --no-check -A callback.ts (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Original prompt

目的:

  • ddc.vim の source.gather を外部入力や新しい補完リクエストが来たときに確実にキャンセルできるようにし、素早い連続入力時の CPU 使用/不要な処理を削減する。

要求仕様:

  1. 互換性
  • 既存の source implementations(user が書いた .gather 実装)を破壊しないこと。
  • 新 API はオプション(args.signal?: AbortSignal)として渡し、ソース実装は既存の args を受け取っている限り動作する。
  1. キャンセル発火条件
  • 新しい onEvent が dispatcher に来たとき(既に cbContext.revoke() で callback 待ちはキャンセル済)に、in-flight の gather を即時キャンセルする。
  • ddc.doCompletion / gatherResults の in-flight token が無効化された場合、関連する gather の abort を発火させる(AbortController.abort)。
  1. 実装要点(ファイル/関数)
  • denops/ddc/types.ts

    • GatherArguments に signal?: AbortSignal を追加(オプショナルなので既存互換)
  • denops/ddc/ext.ts

    • callSourceGather の args に signal を渡すようにする。内部では deadline(source.gather(args), timeout) の代わりに Promise.race([deadline(...), abortPromise]) を用いる。abortPromise は signal.aborted で reject し、reject 値は isDdcCallbackCancelError で検出できるよう name を 'DdcCallbackCancelError' にセットして投げる。
  • denops/ddc/ddc.ts

    • gatherResults / doCompletion を呼ぶ側で AbortController を作り、callSourceGather 呼び出し時に signal を渡す。
    • 既に導入済みの in-flight token 処理と連携し、token が無効化されたら controller.abort() する。dispatcher.onEvent で cbContext.revoke() を行っているので、同タイミングで古い controller を abort する実装にする(controller を Ddc インスタンスのフィールドに保存し、revoke や新トークン発行時に abort して置換)。
  • denops/ddc/callback.ts

    • 既存の isDdcCallbackCancelError 判定はエラー名 'DdcCallbackCancelError' を見ているため、abort 時に投げるエラーの name を同じにして無視させる設計にする。
  1. エラーハンドリング
  • callSourceGather は現在 isDdcCallbackCancelError を無視する実装になっているので、abort 時に投げるエラーは isDdcCallbackCancelError に一致するように name をセットして投げる。
  1. テスト
  • 既存のテスト(convertKeywordPattern 等)に影響しないことを確認
  • 新たに gather の cancellation を確認する単体テストを追加(mock source.gather が signal を受け取るパスと、従来の source.gather が signal を無視するパスの両方をテスト)。

変更の詳細(差分レベルの案)

A) types.ts

  • GatherArguments に signal?: AbortSignal を追加。

B) ext.ts の callSourceGather の変更(要旨)

// before: return await deadline(source.gather(args), sourceOptions.timeout);
const abortPromise = new Promise((_res, rej) => {
  if (args.signal) {
    if (args.signal.aborted) {
      const e = new Error('gather aborted'); (e as any).name = 'DdcCallbackCancelError'; rej(e);
    } else {
      args.signal.addEventListener('abort', () => {
        const e = new Error('gather aborted'); (e as any).name = 'DdcCallbackCancelError'; rej(e);
      }, { once: true });
    }
  }
});
return await Promise.race([deadline(source.gather(args), sourceOptions.timeout), abortPromise]);

C) ddc.ts 側(doCompletion / gatherResults)

  • doCompletion の先頭または gatherResults 呼び出し前に const controller = new AbortController(); this.#currentGatherController?.abort(); this.#currentGatherController = controller; pass controller.signal into callSourceGather via gatherResults.
  • トークン無効化時(this.#currentCompletionToken changes or cbContext.revoke()) call controller.abort().

注意点

  • source.gather が同期的に CPU バウティングな処理をしている場合(Promise になっていても内部で CPU-hot loop をしている場合)、AbortSignal のチェックがなければ直ちに中断できない点はソース実装者にドキュメントとして伝える必要があります(opt-in で signal を使う実装へ移行してもらう)。
  • 既存の非対応ソースに対しても挙動は安全(signal を渡しても無視して処理を続けるが、callSourceGather のレイヤで race しているため、abortPromise が先にrejectすれば結果は無視される。ただし、ソースが同期ブロッキングをしていると abort 時に即時止められない)。

作業手順

  1. ブランチ作成: fix/gather-cancel
  2. 型定義の変更をコミット
  3. ext.ts, ddc.ts の変更を実装、型チェック、テストの追加
  4. Draft PR を作成し URL を共有

よろしければこの内容で PR を作成します。

The following is the prior conversation context from the user's chat exploration (may be truncated):

User: Shougo/ddc.vim のパフォーマンスを改善したいです。特にユーザーが素早く入力したときに CPU 使用率が上がってしまう問題があります

Assistant: 続行するには先ほどのツール呼び出しの許可ダイアログで応答していただく必要があるため、ご返答をお待ちしています。

User: @copilot Accepted Confirmation: Are you sure?

Assistant: 結論(まずやるべきこと)

  • まずは設定で緩和できる即効策を試し、同時に軽い計測(ログ追加+CPUモニタ)をしてホットスポットを特定します。その後、リポジトリを深掘りして具体的な設計変更案/PR(差分)を作ります。リポジトリ調査と PR 作成を進めてよいですか?

やることの流れ(短く)

  1. 今すぐ試せる緩和策を適用して効果を確認する。
  2. 軽い計測(ログ+CPUモニタ)でどの処理が頻発しているか把握する。
  3. リポジトリ横断で入力→補完までのフローを追跡し、CPUホットスポットを特定する(ソース/フィルタ/同期呼び出しなど)。
  4. 優先度付きの改善案を作り、差分(patch/PR)案を提示・作成する。

今すぐ試せる緩和策(優先度高)

  • 入力→補完トリガーに debounce(待ち)を入れる
    • 「最後のキー入力から X ms 経ってから補完処理を走らせる」方式。X は 50–200ms を試す。高速入力で補完を連続再計算しないようにするだけで CPU 使用は大きく下がります。
  • 候補数の上限を下げる(ソース側で返す候補数を制限)
    • 大量候補に対するフィルタ/ソートが高コストな場合に有効。まず 50–100 程度に制限して試す。
  • 重いフィルタ・ソートを同期で呼ばない/頻度を下げる
    • ソースや filter が毎入力で重い vim.fn 呼び出しや正規表現をやっているなら、非同期化または頻度削減を検討。
  • 補完のプレビュー/リスト更新の頻度を下げる
    • UI 更新(popup)の再描画コストを下げるため、更新条件を厳しくする(テキストが実質変わったら更新等)。

簡単な計測(ユーザー側で素早くできること)

  • CPUモニタ(端末側)
    • htop / top で nvim の CPU を観察。高速入力中にどのプロセスが何%使うか確認。PID を控える。
  • 軽いログ出力を ddc のキートリガー直後/候補取得前後に挿入して時間を取得する例(Vimscript):
    • 例(テスト用、実行前にバックアップを):
      " /tmp/ddc_prof.log に追記する簡易ログ
      function! s:log(msg)
        let l = printf('%s %s', reltimefloat(reltime()), a:msg)
        call writefile([l], '/tmp/ddc_prof.log', 'a')
      endfunction
      
      " 補完トリガー直後に呼ぶ例
      call s:log('on_input start')
      " → 候補取得前後にも log を入れる
      call s:log('gather_candidates start')
      " gather_candidates 終了時
      call s:log('gather_candidates end')
    • 上記ログを高速入力で取得し、差分(期間)を確認することで何が多く走っているか推定可能。
  • プロファイラ(Vim/Neovim の :profile)を使う(より詳細)
    • :profile start / :profile func / :...

This pull request was created from Copilot chat.

Copilot AI and others added 3 commits April 28, 2026 11:37
- 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>
- 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>
- 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>
Copilot AI changed the title [WIP] Add abort signal to ddc.vim source.gather for improved cancellation feat: cancel in-flight source.gather on new input events via AbortSignal Apr 28, 2026
Copilot AI requested a review from Shougo April 28, 2026 11:41
@Shougo Shougo marked this pull request as ready for review April 29, 2026 07:26
@Shougo Shougo merged commit a0aa281 into main Apr 29, 2026
3 checks passed
@Shougo Shougo deleted the copilot/add-abort-signal-to-gather branch April 29, 2026 07:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants