Szymon Wilczek

research / neovim/neovim / fix(treesitter) / 0c3e6e1b0e6e

zero parser userdata pointer after ts_parser_delete

neovim/neovim

fix(treesitter) +28 −5

From: Szymon Wilczek <[email protected]>
Date: 29 Apr 2026
Subject: fix(treesitter): zero parser userdata pointer after ts_parser_delete
Pull request: #39497
Merged: 7 May 2026, by Justin M. Keyes

Problem

parser_gc() calls ts_parser_delete() but leaves the userdata pointer pointing to freed memory. If the GC finalizer runs at an unexpected time (e.g. inside nvim_buf_get_lines as seen in #39411), a stale pointer could cause a crash.

Solution

  • NULL out *ud after ts_parser_delete() in parser_gc()
  • Update parser_check() to handle NULL with a clear error message, guarding all parser methods against UAF

Fixes #39411

Draft - opening to run CI and discuss approach before requesting review.

signed, reported, fixes

  • Author Szymon Wilczek author
  • Merged-by Justin M. Keyes maintainer · merged it

diff

src/nvim/lua/treesitter.c +8 −5
@@ -399,10 +399,10 @@ static int tslua_push_parser(lua_State *L)   return 1; } -static TSParser *parser_check(lua_State *L, uint16_t index)+static TSParser *parser_check(lua_State *L, int index) {   TSParser **ud = luaL_checkudata(L, index, TS_META_PARSER);-  luaL_argcheck(L, *ud, index, "TSParser expected");+  luaL_argcheck(L, *ud != NULL, index, "Parser has been deleted");   return *ud; } @@ -419,9 +419,12 @@ static void logger_gc(TSLogger logger)  static int parser_gc(lua_State *L) {-  TSParser *p = parser_check(L, 1);-  logger_gc(ts_parser_logger(p));-  ts_parser_delete(p);+  TSParser **ud = luaL_checkudata(L, 1, TS_META_PARSER);+  if (*ud) {+    logger_gc(ts_parser_logger(*ud));+    ts_parser_delete(*ud);+    *ud = NULL;+  }   return 0; } 
test/functional/treesitter/parser_spec.lua +20 −0
@@ -8,6 +8,8 @@ local eq = t.eq local insert = n.insert local exec_lua = n.exec_lua local feed = n.feed+local matches = t.matches+local pcall_err = t.pcall_err local run_query = ts_t.run_query local assert_alive = n.assert_alive @@ -107,6 +109,24 @@ describe('treesitter parser API', function()     )   end) +  it('ignores repeated parser finalization', function()+    matches(+      'Parser has been deleted',+      pcall_err(exec_lua, function()+        vim.treesitter.language.add('c')++        local parser = vim._create_ts_parser('c')+        -- The parser metatable exposes __gc through __index, so this simulates a finalizer+        -- running before all Lua references to the userdata are gone.+        parser:__gc()+        parser:__gc()++        parser:parse(nil, 'int x;', false)+      end)+    )+    assert_alive()+  end)+   it('respects eol settings when parsing buffer', function()     insert([[       int main() {

discussion · on the pull request

v1 · 14 messages

szymonwilczek author 29 Apr 2026

fix(treesitter): zero parser userdata pointer after ts_parser_delete

szymonwilczek author 29 Apr 2026

CI is fine (without that one flake). What do you think of this approach @justinmk?

barrettruth reviewer 5 May 2026

why is this type changed (just curious) also in my experience if it's ready mark it ready so there's least possible friction to get your prs up and in

szymonwilczek author 5 May 2026

I've changed type to int to align with the luaL_checkudata signature which takes an int. Small cleanup while I was touching the function. Also, thanks for the advice, but I'm keeping is as draft on purpose; until @justinmk has a chance to look - we discussed the approach in #39411 and I want his sign-off before requesting the final review

justinmk maintainer 5 May 2026

I usually ignore Draft PRs (that's why Draft is useful for reviewers :)

lewis6991 reviewer 7 May 2026

This test catches the bug: it passes on this PR and crashes on the parent commit.
diff --git a/test/functional/treesitter/parser_spec.lua b/test/functional/treesitter/parser_spec.lua
index 8678761e6e..9704507411 100644
--- a/test/functional/treesitter/parser_spec.lua
+++ b/test/functional/treesitter/parser_spec.lua
@@ -107,6 +107,27 @@ describe('treesitter parser API', function()
     )
   end)
 
+  it('ignores repeated parser finalization', function()
+    eq(
+      { false, true },
+      exec_lua(function()
+        vim.treesitter.language.add('c')
+
+        local parser = vim._create_ts_parser('c')
+        -- The parser metatable exposes __gc through __index, so this simulates a finalizer
+        -- running before all Lua references to the userdata are gone.
+        parser:__gc()
+        parser:__gc()
+
+        local ok, err = pcall(function()
+          parser:parse(nil, 'int x;', false)
+        end)
+        return { ok, tostring(err):find('Parser has been deleted', 1, true) ~= nil }
+      end)
+    )
+    assert_alive()
+  end)
+
   it('respects eol settings when parsing buffer', function()
     insert([[
       int main() {

szymonwilczek author 7 May 2026

Thanks @lewis6991 for providing the test case! I've verified it locally and added it to the PR.

justinmk maintainer 7 May 2026

don't think this needs to be changed? or change it to Parser not found ? "has been deleted" assumes that it existed, is that a valid assumption?

justinmk maintainer 7 May 2026

does it work if you change this to:
        parser:parse(nil, 'int x;', false)
and then use t.pcall_err(exec_lua, function() ...) in the test call, so we can assert the message more clearly

szymonwilczek author 7 May 2026

original message implied that the wrong type was passed, but after the GC fix this check also triggers when the parser was valid but ha since been finalized - the userdata is a TSParser, its just been deleted. I think "Parser has been deleted" tells the caller exactly what happened and why the operation failed. "Parser not found" sounds to me like a lookup failure rather than lifecycle issue. Happy to change the wording if you have a preference, though

justinmk maintainer 7 May 2026

if the message will make sense then no problem

szymonwilczek author 7 May 2026

That's cleaner. I'll update the test. Will push the update shortly.

lewis6991 reviewer 7 May 2026

I think we generally prefer the uint*_t types where we can use them.

neovim-backports[bot] bot 7 May 2026

Successfully created backport PR for release-0.12: - #39651
the whole thread on github.com
12/12
salina
aurum
herbarium
mysterium
parchment
whispergrove
M-x 0/0

keybindings & navigation

M-x / Alt+x Open Emacs command runner
Shift + W Jump to writings & essays (/writings)
Shift + R Jump to research & upstream work (/research)
Shift + T Choose colour theme (au-themes-select)
j / k Scroll down / up
g g Scroll to the top of page
Shift + G Scroll to the bottom of page
? Toggle this keybindings help cheat sheet
Esc / C-g Dismiss modal or close active menu