Repository: neovim/neovim
Pull request #33005: fix(log): unify error messages for vim.ui_attach/decor providers
Author: login: luukvbaal
Created: 2025-03-20T12:48:05Z | Merged: 2025-03-21T10:05:02Z

Problem:  Error messages that cause a vim.ui_attach() namespace to
          detach are not visible in the message history. Decoration
          provider and vim.ui_attach error messages are dissimilar.
Solution: Emit vim.ui_attach() errors as an actual message in addition
          to logging it. Adjust error message format.

Fix #32917

Linked issue #32917: mention the error that caused "Excessive errors in vim.ui_attach() callback"
### Problem

(partially moved from https://github.com/neovim/neovim/discussions/32916)

`ui_remove_cb()` in `src/nvim/ui.c` switches back to the built-in UI if a custom `vim.ui_attach()` callback errored 10 times. The user gets (almost) constant message `Excessive errors in vim.ui_attach() callback from ns: %d.` without details of the original error.



### Expected behavior

The original error details (with traceback?) are
- appended to the existing message, so it's immediately visible to the user,
- stored in the log file.
Linked issue comments: user: login: luukvbaal
created_at: 2025-03-20T11:07:26Z
body: The errors are already logged if `NVIM_LOG_FILE` is set:
https://github.com/neovim/neovim/blob/175c09bd660d0cea62288e74cea925a9b15bee55/src/nvim/ui.c#L737-L738

We could emit it as a regular message as well so that it will be visible to view in the message history after detachment?
author_association: MEMBER
user: login: luukvbaal
created_at: 2025-03-20T11:22:56Z
body: Not sure what the original idea behind just logging the error was, instead of also emitting it as a message @bfredl? Seems fine to emit the message like we do for any error a users' plugin makes, don't think vim.ui_attach plugins need to be treated differently in that respect. Though maybe it could be guarded behind `set verbose=3	UI info, terminal capabilities.` if the idea is to not bother the user with UI errors.
author_association: MEMBER
user: login: bfredl
created_at: 2025-03-20T11:36:11Z
body: just to avoid feedback if the error comes from a msg_show callback. but that reasoning need not apply for _other_ ui widgets of course, so those could be changed to messages. 
author_association: MEMBER
user: login: luukvbaal
created_at: 2025-03-20T11:40:12Z
body: I'll make a PR that mimics the logic we have in place for decoration providers:
https://github.com/neovim/neovim/blob/175c09bd660d0cea62288e74cea925a9b15bee55/src/nvim/decoration_provider.c#L35-L40

Could exclude msg_show to avoid recursion if you insist, though even that seems fine now that we detach on excessive errors. 
author_association: MEMBER
user: login: bfredl
created_at: 2025-03-20T12:00:21Z
body: I have not insisted on anything, I just answered the question about why the code was written in a way that actually would work (at least as a first prototype).

> We could emit it as a regular message as well so that it will be visible to view in the message history after detachment?

Might still be helpful to see the first `msg_show` that fails in case of feedback. This could be done by buffering enough messages, instead of special-casing `msg_show` per se, I suppose.
author_association: MEMBER
user: login: bfredl
created_at: 2025-03-20T12:07:02Z
body: or as a quick and dirty hack, maybe just set the `keep` flag of the _first_ message causing the first msg_show (which can be anything unrelated and not even an error) if it wasn't already set, so interactive advice to check `:messages` would be truthful. 
author_association: MEMBER
user: login: luukvbaal
created_at: 2025-03-20T13:02:42Z
body: > I have not insisted on anything, I just answered the question about why the code was written in a way that actually would work (at least as a first prototype).

Well you suggested that we should still only apply it to other events. In any case I didn't say you insisted. I meant to ask if you did, despite that it no longer result in problems due to automatic detachment.

#33005 improves the status quo. I'm not sure we need to consider that the first error will _definitely_ be visible. It usually will be, at least with the default `messageopts:history`.

author_association: MEMBER
user: login: przepompownia
created_at: 2025-03-21T15:43:51Z
body: Thank you all for your contributions!
author_association: CONTRIBUTOR

Diff:
diff --git a/src/nvim/decoration_provider.c b/src/nvim/decoration_provider.c
index 2dbf8dd5d99606..902e995ad56465 100644
--- a/src/nvim/decoration_provider.c
+++ b/src/nvim/decoration_provider.c
@@ -23,8 +23,6 @@
 # include "decoration_provider.c.generated.h"
 #endif
 
-enum { DP_MAX_ERROR = 3, };
-
 static kvec_t(DecorProvider) decor_providers = KV_INITIAL_VALUE;
 
 #define DECORATION_PROVIDER_INIT(ns_id) (DecorProvider) \
@@ -34,9 +32,9 @@ static kvec_t(DecorProvider) decor_providers = KV_INITIAL_VALUE;
 
 static void decor_provider_error(DecorProvider *provider, const char *name, const char *msg)
 {
-  const char *ns_name = describe_ns(provider->ns_id, "(UNKNOWN PLUGIN)");
-  ILOG("error in provider %s.%s: %s", ns_name, name, msg);
-  msg_schedule_semsg_multiline("Error in decoration provider %s.%s:\n%s", ns_name, name, msg);
+  const char *ns = describe_ns(provider->ns_id, "(UNKNOWN PLUGIN)");
+  ELOG("Error in decoration provider \"%s\" (ns=%s):\n%s", name, ns, msg);
+  msg_schedule_semsg_multiline("Error in decoration provider \"%s\" (ns=%s):\n%s", name, ns, msg);
 }
 
 // Note we pass in a provider index as this function may cause decor_providers providers to be
@@ -59,11 +57,11 @@ static bool decor_provider_invoke(int provider_idx, const char *name, LuaRef ref
     return true;
   }
 
-  if (ERROR_SET(&err) && provider->error_count < DP_MAX_ERROR) {
+  if (ERROR_SET(&err) && provider->error_count < CB_MAX_ERROR) {
     decor_provider_error(provider, name, err.msg);
     provider->error_count++;
 
-    if (provider->error_count >= DP_MAX_ERROR) {
+    if (provider->error_count >= CB_MAX_ERROR) {
       provider->state = kDecorProviderDisabled;
     }
   }
diff --git a/src/nvim/lua/executor.h b/src/nvim/lua/executor.h
index 9ff869e2213efd..557c0b025e8961 100644
--- a/src/nvim/lua/executor.h
+++ b/src/nvim/lua/executor.h
@@ -44,6 +44,9 @@ typedef enum {
   kRetLuaref,  ///< return value becomes a single Luaref, regardless of type (except NIL)
 } LuaRetMode;
 
+/// Maximum number of errors in vim.ui_attach() and decor provider callbacks.
+enum { CB_MAX_ERROR = 3, };
+
 /// To use with kRetNilBool for quick truthiness check
 #define LUARET_TRUTHY(res) ((res).type == kObjectTypeBoolean && (res).data.boolean == true)
 
diff --git a/src/nvim/ui.c b/src/nvim/ui.c
index 51815c36e1df81..e203e66ad1ffb1 100644
--- a/src/nvim/ui.c
+++ b/src/nvim/ui.c
@@ -6,6 +6,7 @@
 #include <string.h>
 #include <uv.h>
 
+#include "nvim/api/extmark.h"
 #include "nvim/api/private/helpers.h"
 #include "nvim/api/private/validate.h"
 #include "nvim/api/ui.h"
@@ -712,6 +713,13 @@ void ui_grid_resize(handle_T grid_handle, int width, int height, Error *err)
   }
 }
 
+static void ui_attach_error(uint32_t ns_id, const char *name, const char *msg)
+{
+  const char *ns = describe_ns((NS)ns_id, "(UNKNOWN PLUGIN)");
+  ELOG("Error in \"%s\" UI event handler (ns=%s):\n%s", name, ns, msg);
+  msg_schedule_semsg_multiline("Error in \"%s\" UI event handler (ns=%s):\n%s", name, ns, msg);
+}
+
 void ui_call_event(char *name, bool fast, Array args)
 {
   bool handled = false;
@@ -735,7 +743,7 @@ void ui_call_event(char *name, bool fast, Array args)
       handled = true;
     }
     if (ERROR_SET(&err)) {
-      ELOG("Error executing UI event callback: %s", err.msg);
+      ui_attach_error(ns_id, name, err.msg);
       ui_remove_cb(ns_id, true);
     }
     api_clear_error(&err);
@@ -792,13 +800,14 @@ void ui_add_cb(uint32_t ns_id, LuaRef cb, bool *ext_widgets)
 void ui_remove_cb(uint32_t ns_id, bool checkerr)
 {
   UIEventCallback *item = pmap_get(uint32_t)(&ui_event_cbs, ns_id);
-  if (item && (!checkerr || ++item->errors > 10)) {
+  if (item && (!checkerr || ++item->errors > CB_MAX_ERROR)) {
     pmap_del(uint32_t)(&ui_event_cbs, ns_id, NULL);
     free_ui_event_callback(item);
     ui_cb_update_ext();
     ui_refresh();
     if (checkerr) {
-      msg_schedule_semsg("Excessive errors in vim.ui_attach() callback from ns: %d.", ns_id);
+      const char *ns = describe_ns((NS)ns_id, "(UNKNOWN PLUGIN)");
+      msg_schedule_semsg("Excessive errors in vim.ui_attach() callback (ns=%s)", ns);
     }
   }
 }
diff --git a/test/functional/lua/ui_event_spec.lua b/test/functional/lua/ui_event_spec.lua
index 5dcb13a7d01315..1b3f128b9dc052 100644
--- a/test/functional/lua/ui_event_spec.lua
+++ b/test/functional/lua/ui_event_spec.lua
@@ -32,6 +32,7 @@ describe('vim.ui_attach', function()
     ]]
 
     screen = Screen.new(40, 5)
+    screen:add_extra_attr_ids({ [100] = { bold = true, foreground = Screen.colors.SeaGreen } })
   end)
 
   local function expect_events(expected)
@@ -215,7 +216,7 @@ describe('vim.ui_attach', function()
       },
       messages = {
         {
-          content = { { '\nSave changes?\n', 6, 10 } },
+          content = { { '\nSave changes?\n', 100, 10 } },
           history = false,
           kind = 'confirm',
         },
@@ -315,51 +316,41 @@ describe('vim.ui_attach', function()
       },
     })
   end)
-end)
-
-describe('vim.ui_attach', function()
-  local screen
-  before_each(function()
-    clear({ env = { NVIM_LOG_FILE = testlog } })
-    screen = Screen.new(40, 5)
-  end)
-
-  after_each(function()
-    check_close()
-    os.remove(testlog)
-  end)
-
-  it('error in callback is logged', function()
-    exec_lua([[
-      local ns = vim.api.nvim_create_namespace('testspace')
-      vim.ui_attach(ns, { ext_popupmenu = true }, function() error(42) end)
-    ]])
-    feed('ifoo<CR>foobar<CR>fo<C-X><C-N>')
-    assert_log('Error executing UI event callback: Error executing lua: .*: 42', testlog, 100)
-  end)
 
   it('detaches after excessive errors', function()
-    screen:add_extra_attr_ids({ [100] = { bold = true, foreground = Screen.colors.SeaGreen } })
+    screen:try_resize(86, 10)
     exec_lua([[
-      vim.ui_attach(vim.api.nvim_create_namespace(''), { ext_messages = true }, function()
-        vim.api.nvim_buf_set_lines(0, -2, -1, false, { err[1] })
+      vim.ui_attach(vim.api.nvim_create_namespace(''), { ext_messages = true }, function(ev)
+        if ev:find('msg') then
+          vim.api.nvim_buf_set_lines(0, -2, -1, false, { err[1] })
+        end
       end)
-    ]])
+      ]])
     local s1 = [[
-      ^                                        |
-      {1:~                                       }|*4
+      ^                                                                                      |
+      {1:~                                                                                     }|*9
     ]]
     screen:expect(s1)
-    feed('QQQQQQ<CR>')
+    feed('Q<CR>')
     screen:expect({
-      grid = [[
-        {9:obal 'err' (a nil value)}                |
-        {9:stack traceback:}                        |
-        {9:        [string "<nvim>"]:2: in function}|
-        {9: <[string "<nvim>"]:1>}                  |
-        {100:Press ENTER or type command to continue}^ |
-      ]],
+      grid = s1,
       messages = {
+        {
+          content = { { "E354: Invalid register name: '^@'", 9, 6 } },
+          history = true,
+          kind = 'emsg',
+        },
+        {
+          content = {
+            {
+              'Error executing callback:\n[string "<nvim>"]:3: attempt to index global \'err\' (a nil value)\nstack traceback:\n\t[string "<nvim>"]:3: in function <[string "<nvim>"]:1>',
+              9,
+              6,
+            },
+          },
+          history = true,
+          kind = 'lua_error',
+        },
         {
           content = { { 'Press ENTER or type command to continue', 100, 18 } },
           history = false,
@@ -367,15 +358,21 @@ describe('vim.ui_attach', function()
         },
       },
     })
-    feed(':1mes clear<CR>:mes<CR>')
+    feed('<CR>:messages<CR>')
     screen:expect([[
-                                              |
-      {3:                                        }|
-      {9:Excessive errors in vim.ui_attach() call}|
-      {9:back from ns: 2.}                        |
-      {100:Press ENTER or type command to continue}^ |
+      {9:Error in "msg_show" UI event handler (ns=(UNKNOWN PLUGIN)):}                           |
+      {9:Error executing lua: [string "<nvim>"]:3: attempt to index global 'err' (a nil value)} |
+      {9:stack traceback:}                                                                      |
+      {9:        [string "<nvim>"]:3: in function <[string "<nvim>"]:1>}                        |
+      {9:Error in "msg_clear" UI event handler (ns=(UNKNOWN PLUGIN)):}                          |
+      {9:Error executing lua: [string "<nvim>"]:3: attempt to index global 'err' (a nil value)} |
+      {9:stack traceback:}                                                                      |
+      {9:        [string "<nvim>"]:3: in function <[string "<nvim>"]:1>}                        |
+      {9:Excessive errors in vim.ui_attach() callback (ns=(UNKNOWN PLUGIN))}                    |
+      {100:Press ENTER or type command to continue}^                                               |
     ]])
-    feed('<cr>')
+    feed('<CR>')
+
     -- Also when scheduled
     exec_lua([[
       vim.ui_attach(vim.api.nvim_create_namespace(''), { ext_messages = true }, function()
@@ -414,14 +411,33 @@ describe('vim.ui_attach', function()
         },
       },
     })
-    feed('<esc>:1mes clear<cr>:mes<cr>')
+    feed('<Esc>:1messages clear<cr>:messages<CR>')
     screen:expect([[
-                                              |
-      {3:                                        }|
-      {9:Excessive errors in vim.ui_attach() call}|
-      {9:back from ns: 3.}                        |
-      {100:Press ENTER or type command to continue}^ |
+      ^                                                                                      |
+      {1:~                                                                                     }|*8
+      {9:Excessive errors in vim.ui_attach() callback (ns=(UNKNOWN PLUGIN))}                    |
+    ]])
+  end)
+end)
+
+describe('vim.ui_attach', function()
+  before_each(function()
+    clear({ env = { NVIM_LOG_FILE = testlog } })
+  end)
+
+  after_each(function()
+    check_close()
+    os.remove(testlog)
+  end)
+
+  it('error in callback is logged', function()
+    exec_lua([[
+      local ns = vim.api.nvim_create_namespace('test')
+      vim.ui_attach(ns, { ext_popupmenu = true }, function() error(42) end)
     ]])
+    feed('ifoo<CR>foobar<CR>fo<C-X><C-N>')
+    assert_log('Error in "popupmenu_show" UI event handler %(ns=test%):', testlog, 100)
+    assert_log('Error executing lua: .*: 42', testlog, 100)
   end)
 
   it('sourcing invalid file does not crash #32166', function()
diff --git a/test/functional/ui/decorations_spec.lua b/test/functional/ui/decorations_spec.lua
index fe414783fd417e..ccc7841856e8fb 100644
--- a/test/functional/ui/decorations_spec.lua
+++ b/test/functional/ui/decorations_spec.lua
@@ -759,6 +759,7 @@ describe('decorations providers', function()
   end)
 
   it('errors gracefully', function()
+    screen:try_resize(65, screen._height)
     insert(mulholland)
 
     setup_provider [[
@@ -767,16 +768,16 @@ describe('decorations providers', function()
     end
     ]]
 
-    screen:expect{grid=[[
-      {2:Error in decoration provider ns1.start:} |
-      {2:Error executing lua: [string "<nvim>"]:4}|
-      {2:: Foo}                                   |
-      {2:stack traceback:}                        |
-      {2:        [C]: in function 'error'}        |
-      {2:        [string "<nvim>"]:4: in function}|
-      {2: <[string "<nvim>"]:3>}                  |
-      {18:Press ENTER or type command to continue}^ |
-    ]]}
+    screen:expect([[
+      // just to see if there was an accident                          |
+      {8:                                                                 }|
+      {2:Error in decoration provider "start" (ns=ns1):}                   |
+      {2:Error executing lua: [string "<nvim>"]:4: Foo}                    |
+      {2:stack traceback:}                                                 |
+      {2:        [C]: in function 'error'}                                 |
+      {2:        [string "<nvim>"]:4: in function <[string "<nvim>"]:3>}   |
+      {18:Press ENTER or type command to continue}^                          |
+    ]])
   end)
 
   it('can add new providers during redraw #26652', function()


Reviews:
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T13:13:17Z
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T13:15:13Z
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T13:16:28Z
user: login: luukvbaal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T13:28:34Z
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T13:48:27Z
user: login: justinmk
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-21T00:05:58Z
user: login: justinmk
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-21T10:04:38Z

Review comments:
diff_hunk: @@ -34,9 +34,9 @@ static kvec_t(DecorProvider) decor_providers = KV_INITIAL_VALUE;
 
 static void decor_provider_error(DecorProvider *provider, const char *name, const char *msg)
 {
-  const char *ns_name = describe_ns(provider->ns_id, "(UNKNOWN PLUGIN)");
-  ILOG("error in provider %s.%s: %s", ns_name, name, msg);
-  msg_schedule_semsg_multiline("Error in decoration provider %s.%s:\n%s", ns_name, name, msg);
+  const char *ns = describe_ns(provider->ns_id, "(UNKNOWN PLUGIN)");
+  ILOG("Error in decoration provider %s callback %s:\n%s", ns, name, msg);
+  msg_schedule_semsg_multiline("Error in decoration provider %s callback %s:\n%s", ns, name, msg);
path: src/nvim/decoration_provider.c
user: login: justinmk
body: let's choose either "provider" or "callback". redundant to say both.

```suggestion
  ILOG("Error in decoration provider \"%s\" (ns=%s):\n%s", name, ns, msg);
  msg_schedule_semsg_multiline("Error in decoration provider \"%s\" (ns=%s):\n%s", name, ns, msg);
```
created_at: 2025-03-20T13:13:17Z
author_association: MEMBER
diff_hunk: @@ -712,6 +713,13 @@ void ui_grid_resize(handle_T grid_handle, int width, int height, Error *err)
   }
 }
 
+static void ui_attach_error(uint32_t ns_id, const char *name, const char *msg)
+{
+  const char *ns = describe_ns((NS)ns_id, "(UNKNOWN PLUGIN)");
+  ILOG("Error executing UI event %s callback in %s:\n%s", name, ns, msg);
+  msg_schedule_semsg_multiline("Error executing UI event %s callback in %s:\n%s", name, ns, msg);
path: src/nvim/ui.c
user: login: justinmk
body: ```suggestion
  ILOG("Error in \"%s\" UI event handler (ns=%s):\n%s", name, ns, msg);
  msg_schedule_semsg_multiline("Error in \"%s\" UI event handler (ns=%s):\n%s", name, ns, msg);
```
created_at: 2025-03-20T13:15:13Z
author_association: MEMBER
diff_hunk: @@ -798,7 +806,8 @@ void ui_remove_cb(uint32_t ns_id, bool checkerr)
     ui_cb_update_ext();
     ui_refresh();
     if (checkerr) {
-      msg_schedule_semsg("Excessive errors in vim.ui_attach() callback from ns: %d.", ns_id);
+      const char *ns = describe_ns((NS)ns_id, "(UNKNOWN PLUGIN)");
+      msg_schedule_semsg("Excessive errors in vim.ui_attach() callback from %s.", ns);
path: src/nvim/ui.c
user: login: justinmk
body: ```suggestion
      msg_schedule_semsg("Excessive errors in vim.ui_attach() callback (ns=%s)", ns);
```
created_at: 2025-03-20T13:16:28Z
author_association: MEMBER
diff_hunk: @@ -767,16 +768,16 @@ describe('decorations providers', function()
     end
     ]]
 
-    screen:expect{grid=[[
-      {2:Error in decoration provider ns1.start:} |
path: test/functional/ui/decorations_spec.lua
user: login: luukvbaal
body: Replying to your deleted comment @justinmk. Yes maybe this is a reasonable field format actually, it looked like a typo to me in the source😅
created_at: 2025-03-20T13:28:34Z
author_association: MEMBER
diff_hunk: @@ -767,16 +768,16 @@ describe('decorations providers', function()
     end
     ]]
 
-    screen:expect{grid=[[
-      {2:Error in decoration provider ns1.start:} |
path: test/functional/ui/decorations_spec.lua
user: login: justinmk
body: I kind of like it, but not sure if that pattern is understood by many. The `(ns=...)` is probably a more broadly useful pattern for now.
created_at: 2025-03-20T13:48:27Z
author_association: MEMBER

PR comments:
user: login: justinmk
created_at: 2025-03-20T13:24:28Z
body: off-topic: all errors should probably be logged to NVIM_LOG_FILE by default. That would avoid the redundant ILOG calls in this case.
author_association: MEMBER
user: login: przepompownia
created_at: 2025-03-20T13:31:12Z
body: Not sure if the same error should be reported 10 times, but it's definitely better than no details what's happened. 


author_association: CONTRIBUTOR
user: login: justinmk
created_at: 2025-03-20T13:49:03Z
body: > Not sure if the same error should be reported 10 times, but it's definitely better than no details what's happened.

yeah I was wondering why more than 1 error is ever allowed in a decor provider :) off-topic for this pr though.
author_association: MEMBER
user: login: luukvbaal
created_at: 2025-03-20T15:11:11Z
body: I changed it from 10 to 3, matching decoration providers. ~~Still not sure why CI fails, it passes locally.~~ 

Oh, the CI builds do not define `NVIM_LOG_DEBUG`... I guess we use `ELOG` for both of these then?
author_association: MEMBER
user: login: justinmk
created_at: 2025-03-21T11:16:33Z
body: We may need to set a local NVIM_LOG_FILE in these tests (and use `assert_log()`), to avoid noise in the CI logs. https://github.com/neovim/neovim/actions/runs/13989818285/job/39171064521

```
 1 FAILED TEST
------------------------------------------------------------------------------
$NVIM_LOG_FILE: /Users/runner/work/neovim/neovim/build/.nvimlog
(last 100 lines)
ERR 2025-03-21T10:41:45.899 T3659.25283.0 ui_attach_error:719: Error in "msg_show" UI event handler (ns=(UNKNOWN PLUGIN)):
fast context failure
Error executing lua: [string "<nvim>"]:3: attempt to index global 'err' (a nil value)
stack traceback:
	[string "<nvim>"]:3: in function <[string "<nvim>"]:1>
ERR 2025-03-21T10:41:45.906 T3659.25283.0 ui_attach_error:719: Error in "msg_clear" UI event handler (ns=(UNKNOWN PLUGIN)):
```
author_association: MEMBER