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.
This commit is contained in:
Justin M. Keyes
2026-08-19 14:18:26 +02:00
parent 5b1c21f4b8
commit 5a71131282
2 changed files with 24 additions and 9 deletions

View File

@@ -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);

View File

@@ -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)