两个 Bug,一个症状

发布: (2026年2月8日 GMT+8 18:38)
6 分钟阅读
原文: Dev.to

I’m happy to translate the article for you, but I’ll need the text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source link, formatting, markdown, and any code blocks exactly as they are.

背景

一个关于在 Raku MCP SDK 中实现 SSE 客户端传输的调试战斗故事。
任务看起来很直接:向 SDK 添加传统的 SSE 传输。
服务器端进展顺利——Cro 让推送 text/event-stream 响应变得很容易。
然而,客户端却毁掉了整个下午。

症状

is-connected 永远保持 False。没有错误,没有异常,也没有超时信息——什么也没有发生。

初步尝试

我们尝试了几种方法,大致顺序如下:

  • start { await $client.get(...) } – GET 需要 5–10 秒才能解析
  • $client.get(...).then(-> $p { ... }).then 回调同样有延迟
  • react { whenever $resp.body-byte-stream }whenever 未触发
  • Supply.tap(...) – tap 回调延迟
  • RAKUDO_MAX_THREADS=128 – 没有帮助

每种方法单独使用时都能正常工作,但在同一进程中运行 Cro HTTP 服务器时会失败。

根本原因 1:线程池饥饿

Raku 的 start 块、.then 回调以及 react/whenever 都共享同一个 ThreadPoolScheduler。Cro 也使用相同的原语。当 Cro 服务器保持长时间打开的 SSE 流(whenever 块内部的 Supply 管道),而同一进程中的 Cro 客户端需要调度器槽位来解析其 HTTP 响应管道时,它们会争夺同一个池。双方都没有做错什么;饥饿是自然产生的。

调试输出

SSE-CLIENT: before get
connected=False
connected=False
connected=False
SSE-CLIENT: after get, status=200

GET 请求得到了解决,但晚了 10 秒——在测试的轮询循环已经放弃之后。

解决方案:逃离共享池

Thread.start 会在 Raku 调度器之外创建一个真实的 OS 线程。然而,awaitThread.start 内部不起作用;它会悄悄返回 Nil。解决办法是使用 .result,它会在调度器之外同步等待 Promise

method !connect-sse() {
    my $self := self;
    my $url  := $!url;

    Thread.start: {
        my $client = (require ::('Cro::HTTP::Client')).new;
        my $resp = $client.get($url,
            headers => [Accept => 'text/event-stream']).result;

        react whenever $resp.body-byte-stream -> $chunk {
            $self.handle-sse-chunk($chunk);
        }

        CATCH { default {} }
    }
}

更改后,连接已建立,数据流动,块已到达。

根本原因 2:SSE 解析器中的正则空格处理

SSE 解析器收到的行类似于:

event: endpoint
data: http://...

它会在 : 上拆分每一行,得到字段 "event" 和值 " endpoint"。根据 SSE 规范,冒号后的单个前导空格应被剥除。代码尝试这样做:

$value = $value.subst(/^ /, '') if $value.defined;

看起来是正确的,但在 Raku 正则中,空白默认是 不重要的。模式 /^ / 实际上表示“锚定到字符串开头”(^)后跟不重要的空白,而不是字面空格。因此 subst 匹配的是索引 0 处的零宽位置,什么也不替换,返回的仍是原始字符串。事件类型仍然是 " endpoint"(带前导空格),导致检查 $!sse-event-type eq 'endpoint' 失败,POST 端点从未被设置。

修复后调试输出

HANDLE-CHUNK: empty line, event-type=[ endpoint] data=[ http://127.0.0.1:39652/message]

[ endpoint] 中的前导空格就是整个 bug 的根源。

修复:显式剥除空格

完全避免使用正则:

$value = $value.substr(1) if $value.defined && $value.starts-with(' ');

现在事件类型能够正确识别为 "endpoint"

两个错误之间的相互作用

错误 1 阻止了数据及时到达,因此错误 2 是不可见的。修复错误 1 后,症状(is-connected 保持 False)仍然因为错误 2 而持续。系统从未进入部分工作状态;它直接从“因原因 A 而损坏”跳到“因原因 B 而损坏”,行为上没有可观察的变化。

要点

  • Raku 正则表达式中的空白 默认情况下不重要。/^ / 匹配字面空格;它匹配字符串的开头。这在去除前导空格时可能悄悄引入 bug。
  • 线程池饥饿 可能在服务器和客户端共享同一个 ThreadPoolScheduler 的单进程中出现。使用带 .resultThread.start 可以规避此问题。
  • 在生产环境(使用独立进程)中将服务器和客户端运行在同一进程是可以的,但在测试时可能会暴露出新出现的调度问题。
  • 防御性编码(例如,对简单任务使用显式字符串操作而非正则)可以避免细微的陷阱。

感谢 @lizmat 激发了这篇文章的灵感。

0 浏览
Back to Blog

相关文章

阅读更多 »