Skip to content

fix: run the loading spinner tick on the main loop - #178

Merged
vuki656 merged 1 commit into
vuki656:masterfrom
wmaurer:fix/spinner-notify-in-fast-event
Aug 25, 2026
Merged

fix: run the loading spinner tick on the main loop#178
vuki656 merged 1 commit into
vuki656:masterfrom
wmaurer:fix/spinner-notify-in-fast-event

Conversation

@wmaurer

@wmaurer wmaurer commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Problem

A 󰇚 Fetching … notification can still stay on the screen after both jobs have finished, also with #177 merged.

This is a different bug with the same symptom. #177 fixed a lost handle inside the plugin, and that part works: the queue becomes empty, the spinner timer stops, and every handle gets its final message. The notification that stays behind now is one that the notification backend itself does not know about anymore, so even :lua Snacks.notifier.hide() does not remove it. The only way to close it is nvim_win_close on the floating window.

How to reproduce

  1. Open a package.json in a pnpm workspace, with yq on $PATH and snacks.notifier installed.
  2. While the 󰇚 Fetching … notifications are still spinning, open another package.json and go back, a few times (about every 250 ms).
  3. One or more notifications stay on the screen.

A workspace shows this most often, because actions/show.lua starts two jobs there, and every buffer change during loading adds two more loading instances. A simple open without buffer changes almost never shows it.

Cause

M.new starts the spinner timer with a plain callback:

M.state.timer = vim.loop.new_timer()
M.state.timer:start(60, 60, function()
    M.update_spinner()
end)

A libuv timer callback runs in a fast event context, and update_spinner calls vim.notify there. This is not allowed. With the default vim.notify it fails directly:

E5560: nvim_echo must not be called in a fast event context

Plugin users do not see that error, because M.__is_notifying() is only true when a backend is installed, and then vim.notify is a Lua function that does not raise. But the backend still does its own work in that call, and window API is not usable there either (nvim_create_buf already fails in a fast event context). The spinner sends 16 notifications per second per instance, so it hits this very often.

The result with snacks.notifier is a floating window that the notifier never puts in its queue. I checked its internal state during a failing run: the window that stays on the screen has no notification id, hide() is never called for it, and every notification that snacks does know about is closed correctly. So the window has no owner anymore, and nothing can close it.

I did not find the exact line inside snacks where the window is lost, and I think the plugin should not depend on that: sending notifications from a fast event context is wrong on our side, whatever the backend does with it.

Fix

Send the notifications from the main loop:

local tick = vim.schedule_wrap(function()
    if not M.state.timer then
        return
    end

    M.update_spinner()
end)

M.state.timer = vim.loop.new_timer()
M.state.timer:start(60, 60, tick)

vim.schedule_wrap moves each tick out of the fast event context. The guard is for a tick that was already scheduled when reset_state() closed the timer, so a finished spinner does not send one more notification.

Nothing else changes: update_spinner itself is not touched, and the spinner still runs every 60 ms.

Measurement

I ran a pnpm workspace with a script that changes the package.json buffer every 250 ms while the jobs are running, and counted the notification windows that are still open 16 seconds later. The runs with and without the fix were interleaved, so a slow or fast machine affects both the same way.

runs with a notification left on the screen
before 5 / 10
after 0 / 10

Tests

The vim.notify stub in loading_status_spec.lua now also records vim.in_fast_event() for every notification. The new case starts a loading instance, waits for a real timer tick, and checks that no notification was sent from a fast event context.

The case fails on master (Left: true, Right: false) and passes with the fix.

make test passes (117 cases), and stylua --check . is clean.

Neovim version: v0.12.4. Notification backend used for testing: snacks.notifier.

`new` started the spinner timer with a plain callback, so `update_spinner` ran in a fast event context and called `vim.notify` from there. Notification backends open and close floating windows in that call - snacks.notifier does it synchronously from `add` - which is not allowed in a fast event, and the window it opens is dropped before the backend registers it. That leaves a `Fetching ...` toast on screen that nothing can close afterwards, since the backend has no record of it: `Snacks.notifier.hide()` does not touch it and the plugin never held a handle for it either.

This is a separate bug from the lost handle fixed in vuki656#177 and predates it. It shows up in a pnpm workspace because `show` starts two jobs there, and every buffer switch during loading adds another pair of instances, so the 16 replaces per second per instance get plenty of chances to race.

Wrap the tick in `vim.schedule_wrap` so every notification is sent from the main loop, and bail out of a tick scheduled before `reset_state` closed the timer.

Measured on a real pnpm workspace, switching package.json buffers every 250ms while the jobs are in flight, 10 runs each, interleaved: 5/10 runs left a stranded toast before, 0/10 after.
@vuki656
vuki656 merged commit e246949 into vuki656:master Aug 25, 2026
4 checks passed
@vuki656

vuki656 commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Thanks <3

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