From 5b1c21f4b89f9afdabf524e55887a9c1c0052069 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Wed, 19 Aug 2026 13:03:01 +0200 Subject: [PATCH 1/3] fix(lifecycle): late signal kills Nvim mid-teardown Problem: A deadly signal arriving during teardown can kill Nvim while it is preserving swapfiles. `os_exit()` ignores deadly signals via `signal_reject_deadly()`, but `signal_teardown()` => `uv_signal_stop()` resets them to the default behavior, so SIGHUP arriving after that kills the process: [Process exited 129] // 128 + SIGHUP This is a race when closing a pty: kernel sends SIGHUP to foreground process group *and* the reads return EOF, so `chanclose()` on a TUI job prepares to exit twice. This means it is unpredictable whether Nvim exits 1 or is terminated. Solution: Ignore deadly signals once the watchers are closed. Only SIGKILL interrupts it now. --- src/nvim/os/signal.c | 18 ++++++++++++++++++ test/functional/api/vim_spec.lua | 6 +----- .../ex_cmds/swapfile_preserve_recover_spec.lua | 3 +-- test/functional/terminal/tui_spec.lua | 6 ++++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/nvim/os/signal.c b/src/nvim/os/signal.c index 712309fff1..21ad81c09c 100644 --- a/src/nvim/os/signal.c +++ b/src/nvim/os/signal.c @@ -66,9 +66,27 @@ void signal_init(void) signal_start(); } +/// During shutdown, we don't want the default actions of these signals. +/// +/// Note: Windows still has the race. libuv's console control handler dispatches CTRL_CLOSE_EVENT +/// as SIGHUP, but only while a watcher is registered; signal_teardown() closed ours, so the +/// console terminates Nvim with STATUS_CONTROL_C_EXIT (-1073741510). +static void signal_ignore_deadly(void) +{ +#ifndef MSWIN + signal(SIGHUP, SIG_IGN); + signal(SIGINT, SIG_IGN); + signal(SIGTERM, SIG_IGN); +# ifdef SIGQUIT + signal(SIGQUIT, SIG_IGN); +# endif +#endif +} + void signal_teardown(void) { signal_stop(); + signal_ignore_deadly(); signal_watcher_close(&spipe, NULL); signal_watcher_close(&shup, NULL); signal_watcher_close(&sint, NULL); diff --git a/test/functional/api/vim_spec.lua b/test/functional/api/vim_spec.lua index 73a9aea87d..ede230693f 100644 --- a/test/functional/api/vim_spec.lua +++ b/test/functional/api/vim_spec.lua @@ -3254,11 +3254,7 @@ describe('API', function() -- On Windows, even though Nvim TUI handles SIGHUP, it's not possible for the -- parent process to know that, so exit code reflects SIGHUP. expected2.exitcode = (is_os('win') and 129 or 1) - local chaninfo2 = eval('nvim_get_chan_info(&channel)') - if t.is_asan() and chaninfo2.exitcode == 129 then - expected2.exitcode = 129 -- FIXME: SIGHUP sometimes isn't caught with ASAN. 96d6042689064 - end - eq(expected2, chaninfo2) + eq(expected2, eval('nvim_get_chan_info(&channel)')) -- :terminal with args + stopped process (shell-test). command('enew') diff --git a/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua b/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua index 140e4c0212..d15ea06edd 100644 --- a/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua +++ b/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua @@ -138,8 +138,7 @@ describe("preserve and (R)ecover with custom 'directory'", function() -- n.exec_lua([[vim.uv.kill(vim.fn.jobpid(vim.bo.channel), 'sigterm')]]) command('call chanclose(&channel)') -- Kill the child process. -- Wait for the child process to stop. - -- FIXME: SIGHUP sometimes isn't caught with ASAN. - screen0:expect({ any = t.is_asan() and '%[Process exited %d+%]' or '%[Process exited 1%]' }) + screen0:expect({ any = '%[Process exited 1%]' }) neq(nil, uv.fs_stat(swappath1)) test_recover(swappath1) end) diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua index 25e2475caa..ead5f55e35 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -3669,8 +3669,10 @@ describe('TUI', function() retry(nil, 2000, function() eq(vim.NIL, api.nvim_get_proc(pid)) end) - -- FIXME: SIGHUP sometimes isn't caught with ASAN. - screen:expect({ any = t.is_asan() and '%[Process exited %d+%]' or '%[Process exited 1%]' }) + -- On Windows the console terminates the child with STATUS_CONTROL_C_EXIT (-1073741510). + screen:expect({ + any = is_os('win') and '%[Process exited %-?%d+%]' or '%[Process exited 1%]', + }) -- Closing stdin must skip the DA1 wait. t.assert_nolog('timed out waiting for DA1 response', testlog, 100) end) From 5a71131282010225745d94b068a95fc7eb4db2a9 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Wed, 19 Aug 2026 14:18:26 +0200 Subject: [PATCH 2/3] fix(lifecycle): on Windows, CTRL_CLOSE kills Nvim mid-teardown Problem: `signal_ignore_deadly` doesn't work for Windows, where the console still may terminate Nvim during teardown (after `signal_teardown`), while it is already trying to exit. Besides interrupting any housekeeping we are doing, it results in an unpredictable exit code (flaky tests). [Process exited -1073741510] // 0xC000013A STATUS_CONTROL_C_EXIT Solution: Register our own CTRL_CLOSE_EVENT handler which "blocks" the signal. Note: if exit takes longer than 5s, Windows will consider the process "hung" and kill it anyway. --- src/nvim/os/signal.c | 28 ++++++++++++++++++++++----- test/functional/terminal/tui_spec.lua | 5 +---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/nvim/os/signal.c b/src/nvim/os/signal.c index 21ad81c09c..311344cf06 100644 --- a/src/nvim/os/signal.c +++ b/src/nvim/os/signal.c @@ -66,14 +66,32 @@ void signal_init(void) signal_start(); } +#ifdef MSWIN +/// Swallows console control events during shutdown. +static BOOL WINAPI signal_ctrl_handler(DWORD type) +{ + switch (type) { + case CTRL_CLOSE_EVENT: + // Returning true is not enough; Windows terminates the process as soon as the handler returns. + // So block instead, like libuv's own handler does, and let the exit finish. + Sleep(INFINITE); + return true; + case CTRL_C_EVENT: + case CTRL_BREAK_EVENT: + return true; + default: + return false; + } +} +#endif + /// During shutdown, we don't want the default actions of these signals. -/// -/// Note: Windows still has the race. libuv's console control handler dispatches CTRL_CLOSE_EVENT -/// as SIGHUP, but only while a watcher is registered; signal_teardown() closed ours, so the -/// console terminates Nvim with STATUS_CONTROL_C_EXIT (-1073741510). static void signal_ignore_deadly(void) { -#ifndef MSWIN +#ifdef MSWIN + // Handlers run in reverse registration order, so this one runs before libuv's. + SetConsoleCtrlHandler(signal_ctrl_handler, true); +#else signal(SIGHUP, SIG_IGN); signal(SIGINT, SIG_IGN); signal(SIGTERM, SIG_IGN); diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua index ead5f55e35..1c8e73e5e6 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -3669,10 +3669,7 @@ describe('TUI', function() retry(nil, 2000, function() eq(vim.NIL, api.nvim_get_proc(pid)) end) - -- On Windows the console terminates the child with STATUS_CONTROL_C_EXIT (-1073741510). - screen:expect({ - any = is_os('win') and '%[Process exited %-?%d+%]' or '%[Process exited 1%]', - }) + screen:expect({ any = '%[Process exited 1%]' }) -- Closing stdin must skip the DA1 wait. t.assert_nolog('timed out waiting for DA1 response', testlog, 100) end) From 2fea699b9695b95a55b2b714b472417dec991a71 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Wed, 19 Aug 2026 19:33:25 +0200 Subject: [PATCH 3/3] revert: "fix(lifecycle): on Windows, CTRL_CLOSE kills Nvim mid-teardown" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert commit 5a71131282010225745d94b068a95fc7eb4db2a9 That change seems good in theory, but it consistently causes 2 failures FAILED …/api/vim_spec.lua @ 3213: API nvim_list_chans, nvim_get_chan_info stream=job :terminal channel Expected values to be equal. Expected: { argv = { "D:/a/neovim/neovim/build/bin/nvim.exe", "-u", "NONE", "-i", "NONE" }, exitcode = 129, id = 4, mode = "terminal", pty = "?", stream = "job" ... } Actual: { argv = { "D:/a/neovim/neovim/build/bin/nvim.exe", "-u", "NONE", "-i", "NONE" }, exitcode = 143, id = 4, mode = "terminal", pty = "?", stream = "job", ... } stack traceback: …/api/vim_spec.lua:3257: in function <…/api/vim_spec.lua:3213> FAILED …/terminal/tui_spec.lua @ 3669: TUI exits immediately when stdin is closed …/terminal/tui_spec.lua:3669: retry() attempts: 69 Expected values to be equal. Expected: vim.NIL Actual: { name = "nvim.exe", pid = 2256, ppid = 8200, } stack traceback: …/testutil.lua:98: in function 'retry' …/terminal/tui_spec.lua:3669: in function <…/terminal/tui_spec.lua:3656> --- src/nvim/os/signal.c | 26 +++----------------------- test/functional/terminal/tui_spec.lua | 5 ++++- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/src/nvim/os/signal.c b/src/nvim/os/signal.c index 311344cf06..7f9cdf4ce5 100644 --- a/src/nvim/os/signal.c +++ b/src/nvim/os/signal.c @@ -66,32 +66,12 @@ void signal_init(void) signal_start(); } -#ifdef MSWIN -/// Swallows console control events during shutdown. -static BOOL WINAPI signal_ctrl_handler(DWORD type) -{ - switch (type) { - case CTRL_CLOSE_EVENT: - // Returning true is not enough; Windows terminates the process as soon as the handler returns. - // So block instead, like libuv's own handler does, and let the exit finish. - Sleep(INFINITE); - return true; - case CTRL_C_EVENT: - case CTRL_BREAK_EVENT: - return true; - default: - return false; - } -} -#endif - /// During shutdown, we don't want the default actions of these signals. +/// +/// Note: Windows still has the race. See 5a7113128201 for attempted fix. static void signal_ignore_deadly(void) { -#ifdef MSWIN - // Handlers run in reverse registration order, so this one runs before libuv's. - SetConsoleCtrlHandler(signal_ctrl_handler, true); -#else +#ifndef MSWIN signal(SIGHUP, SIG_IGN); signal(SIGINT, SIG_IGN); signal(SIGTERM, SIG_IGN); diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua index 1c8e73e5e6..ead5f55e35 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -3669,7 +3669,10 @@ describe('TUI', function() retry(nil, 2000, function() eq(vim.NIL, api.nvim_get_proc(pid)) end) - screen:expect({ any = '%[Process exited 1%]' }) + -- On Windows the console terminates the child with STATUS_CONTROL_C_EXIT (-1073741510). + screen:expect({ + any = is_os('win') and '%[Process exited %-?%d+%]' or '%[Process exited 1%]', + }) -- Closing stdin must skip the DA1 wait. t.assert_nolog('timed out waiting for DA1 response', testlog, 100) end)