Skip to content

LSP load issue #161

Open
Open

Description

@demonic-07

Bug Report: New .py file opened via :e in a running nvim never gets its filetype set

1. Environment

  • nvim: v0.12.4 (/usr/local/share/nvim)
  • Distro: LazyVim (~/.config/lazyvim/)
  • lazy.nvim: 11.17.5
  • Plugins dir: ~/.local/share/lazyvim/lazy/
  • Extras enabled: lang.python, lang.toml
  • User config diff: lua/plugins/treesitter.lua (disables treesitter indent for python)
  • nvim source inspected: /tmp/nvim-src/src/nvim/ (from the same version)

2. Symptom

  • nvim new.py from the CLI: works (filetype = python, LSP attaches).
  • :e new.py from inside a running nvim (deferred, i.e. normal interactive use): broken — vim.bo.ft stays "", no FileType event fires, so treesitter/LSP etc. never load.
  • :e issued during startup works; :e issued after startup is broken → timing dependent.

3. Reproduction setup

All tests run headless with the real user config:

env NVIM_APPNAME=lazyvim nvim --headless -u ~/.config/lazyvim/init.lua \
  "+lua dofile('/tmp/lazyvim_repro/traceNN.lua')"

The trace script schedules a vim.defer_fn that runs :e /tmp/.../traceNN.py ~2.5s after startup (i.e. in a running session, vim.v.vim_did_enter == 1), then reports vim.bo.ft and vim.fn.did_filetype().

4. Tests run

4.1. Reproduce & confirm baseline behavior

Test Scenario Result
trace25 deferred :e new.py (no instrumentation of LSP) ft= did=0 → broken
trace33_nofix deferred :e new.py ft= did=0 → broken
trace33 + lazy=false override deferred :e new.py ft=python did=0 → fixed
CLI argv (trace45, no fix) nvim new.py at startup ft=python, LSP: pyright + ruff → works

4.2. Show the ordering of events in the broken case

trace28 — vim.cmd.doautoall is called during the BufNewFile cascade, before filetype detection:

[GO] e new
[DOAU] doautoall { "nvim.lsp.enable FileType" } did=0
[DOAU] doautoall { "nvim.lsp.enable FileType" } did=1
[DOAU] doautoall { "nvim.lsp.enable FileType" } did=1
[DOAU] doautoall { "nvim.lsp.enable FileType" } did=1
[AFTER] ft= did=0

did_filetype() flips 0 → 1 on the very first doautoall, while ft is still "".

trace29 — detection callback runs after the flips, but its setf is a no-op:

[GO] e new
[DOAU] { "nvim.lsp.enable FileType" } ft-before= did-before=0
[DOAU] { "nvim.lsp.enable FileType" } ft-before= did-before=1
[DOAU] { "nvim.lsp.enable FileType" } ft-before= did-before=1
[DOAU] { "nvim.lsp.enable FileType" } ft-before= did-before=1
[FTDETECT-LATE] ft= did=1
[AFTER] ft= did=0

4.3. Identify what triggers the doautoall

trace31 — vim.lsp.enable() is invoked during the lazy-load, first call happens while did=0:

[GO] e new
[LSP.ENABLE] lua_ls did=0
[LSP.ENABLE] pyright did=1
[LSP.ENABLE] ruff did=1
[LSP.ENABLE] taplo did=1
[AFTER] ft= did=0

trace38 — backtrace of vim.lsp.enable in the broken case (abridged):

mason-lspconfig/features/automatic_enable.lua:47  vim.lsp.enable(server)
mason-lspconfig/init.lua:43                       setup()
lazyvim/plugins/lsp/init.lua:272                  config()   <- nvim-lspconfig CONFIG
lazy/core/loader.lua:395                          loader.config
lazy/core/handler/event.lua:85                    lazy_handler_event (BufNewFile)

4.4. Isolate the exact cause (causal proof)

trace32 — monkey-patching vim.cmd.doautoall to skip nvim.lsp.enable FileType:

[GO] e new
[SKIP-DOAU] nvim.lsp.enable FileType   (x4)
[FT-EVT] ft=python
[FT-EVT] ft=python
[AFTER] ft=python did=0

→ With the single doautoall suppressed, detection works. This is direct causal proof.

4.5. Candidate fixes

Test Override Result
trace33 / trace44 / trace53 / t88_fixA nvim-lspconfig lazy = false (load at startup) ft=python, did=0 → fixed
trace33_vl / trace35_vl / trace36_vl nvim-lspconfig event = "VeryLazy" ft= did=0 → NOT fixed
t88_fixB event = function() return { "BufReadPre" } end ft=python but plugin never loads → no LSP → rejected
t88_fixC event = function() return { "BufReadPre", "BufNewFile" } end reproduces bug (ft= empty) → confirms BufNewFile is the trigger
t88_fixD / t88_fixE defer vim.lsp.enable via vim.schedule (proper fix) ft=python + pyright/ruff attach → fixed, LSP preserved

The VeryLazy variant does not help: lazy.nvim still lazy-loads nvim-lspconfig (and its deps mason.nvim/mason-lspconfig.nvim) during the BufNewFile cascade — at that point vim.v.vim_did_enter == 1, so vim.lsp.enable still runs its doautoall.

Why every plain event = "..." override failed: lazy.nvim merges list-valued spec props (event, cmd, ft, keys) with Util.extend (lazy/core/plugin.lua:464-465), which concatenates the base LazyVim spec's event with the override — you can only add triggers, never remove them. So event = "BufReadPost" merged to { "BufReadPre", "BufNewFile", "BufReadPost" } and BufNewFile still fired (verified: Handler.handlers.event.active["BufNewFile"] still contained nvim-lspconfig).

The one override form that replaces: a function-valued event (M._values at plugin.lua:458-461 treats functions as a full replacement). But dropping BufNewFile (fix B) fixed the filetype while breaking LSP entirely for new files (BufReadPre never fires for :e newfile.py), and keeping both (fix C) reproduced the bug. So the event cannot be the fix surface.

4.6. The proper fix (verified): defer vim.lsp.enable

Root cause is the synchronous vim.lsp.enable running inside the BufNewFile load cascade. Deferring it past the cascade preserves lazy-loading on BufNewFile and fixes the filetype:

Test Scenario Result
t88_fixE CLI nvim newfile.py ft=python, clients pyright,ruff, enabled pyright,ruff,lua_ls
t88_fixE deferred :e newfile.py in running session ft=python, clients pyright,ruff, enabled pyright,ruff,lua_ls
t88_fixA (lazy=false) control same two scenarios identical results

Both fixes now produce identical, correct output. The earlier "CLIENTS 0" headless readings in §7 were an artifact of vim.loop.sleep in the trace harness blocking the event loop / LSP client attach — with a vim.defer_fn-chained trace, clients attach in the deferred case too.

5. Root cause (fully confirmed)

  1. :e new.py fires BufNewFile.
  2. lazy.nvim's lazy_handler_event autocmds (registered before the filetypedetect group, so they run first) lazy-load nvim-lspconfig — LazyVim defines it with event = { "BufReadPre", "BufNewFile" } in lua/lazyvim/plugins/lsp/init.lua:5.
  3. During the load, LazyVim's config runs mason-lspconfig.setup() → vim.lsp.enable("lua_ls") etc. (automatic_enable.lua:47).
  4. nvim runtime vim.lsp.enable (/usr/local/share/nvim/runtime/lua/vim/lsp.lua:656-658) sees vim.v.vim_did_enter == 1 (running session) and executes :doautoall nvim.lsp.enable FileType.
  5. That fires the FileType event on the current buffer → apply_autocmds_group sets curbuf->b_did_filetype = true at the start of the batch (src/nvim/autocmd.c:1879).
  6. When the built-in filetypedetect callback (/usr/local/share/nvim/runtime/filetype.lua:8-35) finally runs, vim.filetype.match() returns "python" and it calls :setf python — but ex_setfiletype early-returns because b_did_filetype is already true (src/nvim/ex_docmd.c:8162).
  7. Net result: vim.bo.ft stays "", no FileType event with python ever fires → treesitter/LSP/etc. never attach.

Why CLI/startup works

At startup vim.v.vim_did_enter == 0, so the doautoall branch in vim.lsp.enable (lsp.lua:656, condition vim.v.vim_did_enter == 1 or vim.fn.did_filetype() == 1) is skipped. That is the entire timing dependency.

Why VeryLazy override fails

It changes when lazy.nvim would load the plugin, but the trigger that actually pulls nvim-lspconfig in (and its deps mason.nvim + mason-lspconfig.nvim) is still the BufNewFile cascade, so vim.lsp.enable still runs with vim_did_enter == 1 → same doautoall → same breakage.

6. Possible fixes

Primary (verified, simplest): lazy = false

Load nvim-lspconfig at startup in ~/.config/lazyvim/lua/plugins/:

return {
  { "neovim/nvim-lspconfig", lazy = false },
}

Verified: deferred :e new.py → ft=python, did_filetype()=0; CLI .py file still works. Works because vim.lsp.enable then runs at startup (vim_did_enter == 0) where the doautoall branch is skipped.

Proper fix (verified): defer vim.lsp.enable with vim.schedule

Keeps LSP lazy-loaded on BufNewFile (so LSP still starts for brand-new files) while letting filetype detection finish first. Add a tiny plugin that wraps vim.lsp.enable so the doautoall runs after the current autocmd batch completes:

return {
  {
    "nvim-lspconfig-enable-defer",
    lazy = false,
    init = function()
      local orig_enable = vim.lsp.enable
      vim.lsp.enable = function(server)
        vim.schedule(function()
          orig_enable(server)
        end)
      end
    end,
  },
}

Verified (t88_fixD/fixE): deferred :e new.py → ft=python; CLI → ft=python + pyright,ruff clients. Both behave identically to the lazy = false control. This is the cleanest candidate to upstream as a LazyVim patch (lua/lazyvim/plugins/lsp/init.lua:264 and :272, or automatic_enable.lua:47).

Rejected overrides

  • event = "VeryLazy" — does not fix (still loaded on BufNewFile).
  • Any plain event = "..." string/table override — lazy.nvim concatenates the base event, so BufNewFile cannot be removed (plugin.lua:464).
  • event = function() ... end returning only non-BufNewFile triggers — replaces the event but breaks LSP for new files (BufReadPre doesn't fire for :e new.py).

Upstream neovim note: vim.lsp.enable's doautoall arguably should not mark b_did_filetype on the current buffer when ft is still empty. Related upstream issues: neovim/neovim#31641, neovim/neovim#33116.

7. Caveats / open items

  • LSP attach in the deferred :e case was initially reported as intermittent (n=0 in some headless runs). This was traced to the test harness using vim.loop.sleep, which blocks the event loop and prevents scheduled LSP client attach. With a vim.defer_fn-chained trace, clients attach reliably (pyright,ruff) under both fixes. An interactive confirmation remains a nice-to-have, not a blocker.
  • The lazy = false fix and the vim.schedule defer fix both preserve correct CLI-argv behavior.

8. Conclusion

The bug is an autocmd-ordering race, not a python-specific config issue:

  • lazy.nvim lazy-loads nvim-lspconfig on BufNewFile, before filetypedetect runs.
  • During that load, LazyVim calls vim.lsp.enable(), which (in a running session, vim_did_enter == 1) runs :doautoall nvim.lsp.enable FileType.
  • That FileType batch sets b_did_filetype = true on the current buffer ahead of detection, so the later setf python in filetypedetect becomes a no-op (ex_setfiletype early return).
  • Fix options: (a) load nvim-lspconfig at startup (lazy = false) so vim.lsp.enable runs while vim_did_enter == 0 and the doautoall is skipped; or (b) defer vim.lsp.enable via vim.schedule so the doautoall runs after detection — both verified, identical behavior.
  • Event-only overrides cannot fix it: lazy.nvim concatenates the base event (plugin.lua:464), so BufNewFile can't be removed; function-valued events replace but break LSP for new files.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions