Skip to content

feat(helix-term): Don't block the UI on prompt completions - #16065

Open
kas2020-commits wants to merge 1 commit into
helix-editor:masterfrom
kas2020-commits:async-prompt-completions
Open

feat(helix-term): Don't block the UI on prompt completions#16065
kas2020-commits wants to merge 1 commit into
helix-editor:masterfrom
kas2020-commits:async-prompt-completions

Conversation

@kas2020-commits

@kas2020-commits kas2020-commits commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Hello! I see that #11787 hasn't made progress in a while, so I thought I'd take the mantle on pushing this forward.

Credit to @rhogenson for writing the original version, which I've kept mostly the same. I made a small tweak to not use AsyncHook though. AsyncHook::spawn calls tokio::spawn, which is intended for asyncronous tasks, whereas we're using blocking calls for I/O. tokio::task::spawn_blocking is a slightly better fit for our use-case and also lets us drop the Sync requirement for deferred completers. There's also a few minor naming differences and comments added.

I've been testing it on the google monorepo and It's been working very well for me. It would be great if we could upstream this 🙏

PS: if there's a more formal way to give attribution, please let me know!

Fixes #11604

Completers for typable commands (:open, :cd, :theme, ...) performed
blocking I/O on the main thread on every keystroke.

Completers now return a CompletionResult, which is either an Immediate
result or a Deferred closure which runs on tokio's blocking thread pool.
The prompt waits up to 50ms for deferred results so fast filesystems
behave exactly as before; past that deadline the results are delivered
asynchronously through the job queue once ready. A TaskController
cancels in-flight directory walks (checked per entry) as soon as the
input changes, and swapping out the result receiver guarantees stale
completions are never delivered.

The unused ui::prompt_with_input helper is removed since it could not
be carried over unchanged.
@kas2020-commits
kas2020-commits force-pushed the async-prompt-completions branch from d9bafd3 to 8bff334 Compare July 19, 2026 18:14
@kas2020-commits kas2020-commits changed the title Don't block the UI on prompt completions feat(helix-term): Don't block the UI on prompt completions Jul 20, 2026
@kas2020-commits

Copy link
Copy Markdown
Contributor Author

CC @the-mikedavis for visibility.

@rhogenson rhogenson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM :)

CompletionResult::Immediate(completion) => self.completion = completion,
CompletionResult::Deferred(compute) => {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
tokio::task::spawn_blocking(move || {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous implementation using AsyncHook had the property that it wouldn't spawn a new background completion until the previous one had completed. If the user is typing quickly, the new code here will spawn a background job for each keypress. The results of all these extra background jobs are ignored, but it might cause extra CPU usage or fill up tokio's thread pool.

It might make sense to go back to a strategy where there's a single background worker that only checks for a new background completion when the previous one is finished

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good detail to bring up, but after some thought I'm inclined to prefer this approach a little more?

Important to note here is that the fs walk checks if the task has been canceled on every blocking call (see ui/mod:726). Once the task has been cancelled, it only has to wait for the current blocking call to complete before it completes: it doesn't do the full walk on every keystroke.

Allowing more threads to spawn in parallel brings down the overall latency at the cost of consuming more resources, so it's a balance. I expect the number of concurrent threads active at any time are still ~O(1) or maybe ~O(10) at the highest. At that magnitude, it seems like the right trade-off IMO.

wdyt?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fs "walk" in this case is only depth 1, so it's usually going to be a single readdir call, except in cases where the directory is really huge. So I don't expect to get much meaningful early cancellation here.

It might reduce the latency a little bit to spawn a new background completion immediately, but in practice, once the readdir call is cached by the filesystem, then all subsequent readdirs on the same directory will usually complete quickly. So even if it has to wait for a previous completion to finish, really it's waiting to get the results of that readdir into the filesystem cache which will be used by future completions.

I think it's not possible to put an upper bound on the number of concurrent threads. Imagine that I copy-paste a path into the prompt; it will spawn N background completions where N is the length of the pasted string

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.

Slow autocomplete when opening files over SSHFS

2 participants