Sporadic Rterm Crashes with Completion



这篇文章讲述了 Rterm 中的一个 bug,Rterm 是 Windows 上的控制台 R 前端,这个 bug 困扰了我好几年,但直到两周前才意外出现,让我得以追踪并修复它。

在完成期间,终端有时会崩溃,因此,在我按下 tab 键后,但这种情况非常罕见,似乎没有办法重现问题,而且只发生在 mintty 终端(来自 Msys2,运行 bash)中,而不会发生在 cmd.exe 中。

我认为这与基于流和基于函数调用的控制台控制之间的复杂转换有关,此类终端会执行此转换,并且我认为这不是 R 中的 bug。但是,每当它再次出现时,我仍然感到有些不自在。除非追踪到 bug,否则无法真正知道 bug 不在何处。

两周前,Rterm 在完成期间崩溃,即使在 cmd.exe(默认 Windows 终端)中也是如此,并且连续发生了多次。我会启动 Rterm,按下 tab 键,R 会崩溃,并显示“C 栈使用量接近限制”的错误,给出非常大的栈使用量。因此,显然不是 mintty 的问题。我也可以在 Power Shell 中获得相同的崩溃。

此错误消息通常表示无限递归。在此示例中,

f <- function(x) { print(x); f(x+1) }
f(1)

函数 f 将始终打印嵌套级别并递归调用自身。最终,执行将导致错误

[1] 733
[1] 734
[1] 735
Error: C stack usage  7970564 is too close to the limit
> 

以上实际数字因系统而异,但关键是无限递归会变成常规 R 错误,并且 R 不会崩溃。

完成如何在 R 代码中导致无限递归,它为什么使 R 崩溃,以及它为什么只在极少数情况下崩溃?

完成选项是在 R 代码中计算的,函数 .win32consoleCompletionutils 包中,它是基本 R 的一部分。因此,计算确实在 R 中运行,但为什么它有时只会在 R 的全新启动中运行到无限递归?

我比较幸运,因为该问题在 R 构建中也可以重复出现,而该构建没有 C 编译器优化(-O0),并且包含调试符号。我认为这样就可以轻松地在 gdb 中运行 R 并查看问题所在。

但是,由于某种原因,在 gdb 中运行时不会出现该问题。无论从一开始就在 gdb 中运行 R,还是将已运行的 R 版本附加到 gdb,都不会出现该问题。

我唯一能得到的是来自 windbg 的堆栈跟踪,windbg 是一个在“事后”调用的 Windows 调试器。事后意味着当程序(在本例中为 Rterm)崩溃时,调试器会由 Windows 自动调用

 # Child-SP          RetAddr               Call Site
00 000000e3`8bff82d0 00007ffb`04e6af0a     ntdll!RtlUnwindEx+0x4e2
01 000000e3`8bff89f0 00007ffa`b87061ee     ucrtbase!_longjmp_internal+0xea
02 000000e3`8bff8f30 00007ffa`b8742543     R!R_jumpctxt+0xe9
03 000000e3`8bff8f70 00007ffa`b8741d6b     R!Rf_error+0x575
04 000000e3`8bff91e0 00007ffa`b8741ed5     R!R_curErrorBuf+0xb79
05 000000e3`8bffd4d0 00007ffa`b8746691     R!Rf_errorcall+0x131
06 000000e3`8bfff510 00007ffa`b873f673     R!R_signalErrorConditionEx+0xe7
07 000000e3`8bfff550 00007ffa`b8748667     R!R_SignalCStackOverflow+0xed
08 000000e3`8bfff590 00007ffa`b869868e     R!Rf_eval+0x1e4
09 000000e3`8bfff850 00007ffa`b88eb654     R!get_R_HOME+0x74e
0a 000000e3`8bfffa50 00007ffa`b86a29e0     R!Rf_rsignrank+0x205b
0b 000000e3`8bfffaa0 00007ffa`b86a2888     R!R_WriteConsoleEx+0x308
0c 000000e3`8bfffae0 00007ffb`04e4514b     R!R_WriteConsoleEx+0x1b0
0d 000000e3`8bfffb10 00007ffb`05c77034     ucrtbase!thread_start<void (__cdecl*)(void *),0>+0x7b
0e 000000e3`8bfffb40 00007ffb`075c26a1     KERNEL32!BaseThreadInitThunk+0x14
0f 000000e3`8bfffb70 00000000`00000000     ntdll!RtlUserThreadStart+0x21

并且该应用程序还有一些其他线程,其中之一是

 # Child-SP          RetAddr               Call Site
00 000000e3`fbdfe408 00007ffa`eddf11b0     win32u!NtUserWaitMessage+0x14
01 000000e3`fbdfe410 00007ffa`b86a25b1     Rgraphapp!GA_waitevent+0x1a
02 000000e3`fbdfe440 00007ffa`b86a2953     R!R_WaitEvent+0x16
03 000000e3`fbdfe470 00007ffa`b86a267d     R!R_WriteConsoleEx+0x27b
04 000000e3`fbdfe4b0 00007ffa`b8789ea2     R!R_ReadConsole+0x3e
05 000000e3`fbdfe4e0 00007ffa`b878a26a     R!Rf_ReplIteration+0x7c
06 000000e3`fbdfe550 00007ffa`b878b8cd     R!Rf_ReplIteration+0x444
07 000000e3`fbdff5b0 00007ffa`b878b8eb     R!run_Rmainloop+0x71
08 000000e3`fbdff5e0 00007ff7`10181b9b     R!Rf_mainloop+0x12
09 000000e3`fbdff610 00007ff7`10181592     Rterm+0x1b9b
0a 000000e3`fbdff790 00007ff7`101813c1     Rterm+0x1592
0b 000000e3`fbdff7d0 00007ff7`101814f6     Rterm+0x13c1
0c 000000e3`fbdff8a0 00007ffb`05c77034     Rterm+0x14f6
0d 000000e3`fbdff8d0 00007ffb`075c26a1     KERNEL32!BaseThreadInitThunk+0x14
0e 000000e3`fbdff900 00000000`00000000     ntdll!RtlUserThreadStart+0x21

上面的第二个线程似乎是主 R/Rterm 线程,它正在等待某个 Windows 事件。上面的第一个线程运行到 R 解释器并立即获得堆栈溢出错误。但是,怎么会这样?通常,对于无限递归,应该看到一个带有嵌套调用的深度堆栈,在第一个跟踪中只有一个对 R_eval 的调用。

这让我警觉起来,因为我之前曾在 R 中处理过堆栈检测。堆栈溢出检查基于检查当前帧与已知堆栈起始位置的距离,并给定已知的堆栈增长方向。堆栈起始位置是在 R 启动时检测到的,并存储在全局变量中。

上面的第一个线程使用在上面的第二个线程(R 主线程)中检测到的堆栈起始位置。但是,第一个线程的堆栈确实在其他位置。根据操作系统分配新线程堆栈的位置,R 碰巧检测到堆栈溢出或没有检测到,但它总是对检测使用错误的堆栈起始位置。这解释了为什么该错误如此难以重复,以及为什么非确定性的来源在 R 之外。这也使得它在 gdb 中不发生就不足为奇了。

R 不是线程安全的。如果 R 或包创建了一个新线程,则该线程绝不能调用 R。很明显,必须修复 Windows 上的控制台实现以确保这一点。基本 R 中已经有一个模式:用于提供 HTML 帮助的嵌入式 HTTP 服务器。工作线程也需要运行 R 代码,并且它们要求主 R 线程为它们执行此操作。控制台也必须这样做。

现在,虽然我对崩溃有了一个非常合理的解释,但我确实想要一种完全可重复的方式来触发它,包括在 gdb 中,始终触发它,以便我可以在修复前后进行测试,并确信我已解决了导致(非常零星)崩溃的所有问题。“非常零星”变得相当委婉,因为在重新启动 Windows 机器后,我根本无法再次出现崩溃,即使在 mintty 中,它以前经常发生。

我尝试通过始终在 .win32consoleCompletion 中运行此代码来激发它

f <- function(x) { print(x); f(x+1) }
f(1)

毫不奇怪,这总是会遇到检测到的堆栈溢出错误,但是 R 仍然不会在 gdb 中崩溃。不仅 gdb 会给我带有调试符号的堆栈跟踪(与上面的 windbg 不同),它还显示崩溃不会可靠地重复,因此修复后它的缺失会带来有限的信心。

为了完全隔离检测堆栈溢出和处理完成计算中的错误的问题,我反而添加了

stop("xxx")

.win32consoleCompletion。它使 R 崩溃。完成计算中的 R 错误不应导致崩溃。计算需要针对用于在 R 中实现错误的长跳转进行保护。嵌入式 HTTP 服务器使用 R_ToplevelExec 执行此操作。

因此,此问题实际上涉及两个错误:在新的线程上评估的 R 代码(导致无效的堆栈溢出错误)和未针对错误保护的 R 代码评估。

第二个错误使用 R_ToplevelExec 很容易修复,一个可靠的可重现示例是 R 的修改版本,该版本始终在 .win32consoleCompletion 内运行 stop(),如上所述。

回到第一个错误,我想要一个可靠的可重现示例。在基本 R 中的 main.c 中有代码可以访问 R 认为在其 C 堆栈上的所有字节。

该代码用于调试堆栈开始和堆栈增长方向的检测。我已安排 do_inspect 调用此代码,以便始终访问堆栈的所有(剩余)字节,然后使 .win32consoleCompletion 始终调用 .Internal(inspect(1))。当在 R 计算完成时错误地检测到 C 堆栈时,此修改后的 R 版本会崩溃。这对我来说在 gdb 中也“有效”,最终是一个可重现的示例。

为了修复第一个错误,我必须安排在主 R 线程上运行完成的计算。主 R 线程运行这样的事件循环以从控制台获取一行输入(添加了注释)

SetEvent(EhiWakeUp);
while (1) {
    R_WaitEvent(); // WaitMessage()
    if (lineavailable) break;
    doevent(); // PeekMessage()
               // TranslateMessage()
               // DispatchMessage()
    if(R_Tcl_do) R_Tcl_do();
}
lineavailable = 0;

控制台读取器线程运行这样的循环以从 getline 获取代码行并通知主线程

while(1) {
    WaitForSingleObject(EhiWakeUp,INFINITE);
    tlen = InThreadReadConsole(tprompt,tbuf,tlen,thist);
    lineavailable = 1;
    PostThreadMessage(mainThreadId, 0, 0, 0);
}

需要额外的控制台读取器线程,因为对 InThreadReadConsole 的调用是阻塞的(在 getline 库中),但我们需要在输入行时处理 Windows 事件和 Tcl 事件。因此,在上面,读取器线程设置 lineavailable 并向主线程发送线程消息以将其从 WaitMessage() 中唤醒。

当需要完成时,getline 在读取器线程中调用制表符挂钩函数。我更改了该挂钩,改为要求主线程执行该工作。按照此代码中的模式,可以添加一个标志 completionneeded,通过全局变量传递输入,并添加一个 Windows 事件(信号量的一个变体),主线程将通过该事件通知读取器线程在其他全局变量中获取结果。然而,这种自然扩展在访问全局变量时存在竞争条件(由 Luke Tierney 发现)以及线程消息的另一个竞争条件风险。为了便于演示,我在原始代码中描述了这些条件。

在上面的代码中,变量 lineavailable 在读取器线程中设置,然后发布线程消息。发送该线程消息应该包含内存屏障,因此,在收到消息后,即使 lineavailable 是未锁定的全局变量,主线程也可以安全地访问它。主线程将对变量清零,但读取器线程在 EhiWakeUp 事件之前不会触及它,因此应该保证一致性。但是,如果上面的 R_WaitEvent 向主线程返回了不同的消息,该怎么办?然后,主线程可能会意外地看到 lineavailable 的不一致版本。

在基本版本中,这可能是良性的(对 32 位整数的原子写入是原子的),但在扩展版本中,除了 completionneeded 之外,还会用完成的参数填充其他全局变量,并且主线程可能会以不走运的顺序看到它们,从而获取不完整的参数。

此外,PostThreadMessage 向主线程发送的线程消息存在潜在问题。如果意外地,在主线程上运行的任何代码(通过 doeventR_Tcl_do)包含对 PeekMessage()TranslateMessage()DispatchMessage() 的调用,这将意外地从读取器线程获取线程消息,该消息将丢失,因为它不属于任何窗口。然后,主线程只能在另一个 Windows 消息到达时才能响应行可用或完成请求。

因此,我更改了控制台中的同步,以使用虚拟窗口来接收消息。主线程循环看起来与之前完全一样(如上所述),但 lineavailable 仅在主线程中访问。主线程使用 Windows 过程创建虚拟窗口,该过程在从读取器线程接收消息后设置 lineavailable。类似地,Windows 过程在从读取器线程接收消息后计算完成,并且不需要与主线程中的循环进行任何通信。

这通过不使用线程消息使同步更加健壮,并且使其与嵌入式 HTTP 服务器的工作方式更加相似,从而在一定程度上降低了代码的认知复杂性。

总之,这些崩溃揭示了 Rterm 中的两个错误和两个潜在的竞争条件。对于 Windows 上的普通 R 用户来说,这里介绍的细节无关紧要,只要在他们按下 tab 键时 R 不会崩溃丢失所有工作就足够了。希望它能尽快得到修复,毕竟错误尚未报告。一个“解决方法”确实是不在 Rterm 中使用完成。此修复程序现已包含在 R-devel 中,并将修补到 R 4.2.3 中,对于 R 4.2.2 来说为时已晚。

维护 R 的工作涉及定期调试和修复此类问题。随着操作系统、外部库和硬件的不断发展,过去可以正常工作的代码可能会停止工作,这可能是由于以前良性的错误,甚至是由于外部更改而需要更新的正确代码。

通常无法以这种详细程度报告错误,但我认为这是一个有趣的例子。可以在 R NEWS 文件中找到最重要的/可见的错误修复列表,并且所有更改都可以在版本控制系统中看到。