Visitar URL original
fix(channels): plugin transport survives a daemon child holding stdout · tmoonlight/acecode@c0b1482 · GitHub
Skip to content

Commit c0b1482

Browse files
committed
fix(channels): plugin transport survives a daemon child holding stdout
Found in a real-process run (connector package + daemon + plugin in mock mode): - A plugin that starts its own long-running service hands that service its stdout write end, so the pipe never reaches EOF after the plugin exits. Stopping blocked until the service exited (Disconnect hung for minutes) and a crash went unnoticed. The reader now reads with a timeout (PipedProcess::read_stdout_for) and the supervisor checks every 200 ms whether the plugin process itself has exited. - The plugin reports its status and first inbound messages right after replying to initialize, sometimes before the initialize result was processed: callers saw Connected while capabilities were still defaults and sends failed as not ready. Events that arrive before initialization completes are now kept in order and replayed once it does. Tests: PluginTransportTest.StopDoesNotWaitForAChildProcessHoldingThePipe and CrashIsDetectedWhileAChildProcessHoldsThePipe (fake plugin --spawn-holder), both failing on the previous code; plugin suites pass 40 repeated runs.
1 parent d0136f9 commit c0b1482

7 files changed

Lines changed: 167 additions & 7 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ The frontend has pure helpers under [web/src/lib/](web/src/lib) with Node-based
386386
- **LINE**:唯一要公网回调的平台。回调端口是 `core/line_webhook_server`(独立 Crow 应用,只监听 127.0.0.1,只有 webhook / health / media 三条路由,**绝不复用 daemon 主端口** —— 本机请求在主端口免 token,经隧道进来的也是本机地址);端口第一次由系统分配后存 cursor `line_listen_port` 沿用。公网地址留空 → 起 cloudflared 快速隧道(找不到时 `extra.cloudflared_missing`,设置页提示 winget 安装),每次连接用 PUT 重新登记 webhook;控制台的 Use webhook 只能手动开,没开时停在 Retrying(`extra.webhook_active=false`,设置页给出该做什么)。回复令牌 50 秒内免费用 reply,否则 push(计月额度,额度用完暂存到下一条消息再补发);机器人不能发文件,图片走回调端口的临时链接。
387387
- **Discord**:校验时 `/applications/@me` 报 Message Content Intent 关着就直接拒绝保存(否则网关 4014);保存时带回 `bot_id` / `application_id`(设置页邀请链接用)。优先 Resume,Identify 每 24 小时有上限,计数经 `on_identify_ledger` 存两个 cursor 跨重启保留;4004 / 4010–4014 停止重试。服务器频道与线程算群,@ 或回复机器人才处理;不 ping @everyone。
388388
- **Telegram 群隐私模式开启时 @机器人 的消息照常送达**(BotFather 默认即开启,正好符合“群里只处理 @”);关闭后机器人收到群里全部消息,仍只处理 @ 它的。早期 spec 把这条写反了,已更正。
389-
- **外部平台插件**(openspec add-channel-platform-plugins,协议 [docs/channel-platform-plugin.md](docs/channel-platform-plugin.md)):daemon **只在启动时**扫描 `<data_dir>/connectors/*/channel-platform.json` 注册成平台(装包后要重启),名字不能与内置平台重名,`Address::valid` 的平台名因此只按字符集校验。插件是子进程,stdio JSON lines(`im/plugin/plugin_transport`,退避重启);只支持本人账号 `OwnerBinding::Self`:只收 `sender == account` 的私聊,发送人即机主,没有向导和绑定码,`status` 报需要登录时卡片出「登录」(`POST /api/channels/<p>/login` → `action("login")`)。声明 `watched_groups` 的插件,群消息进 `GroupBuffer`,到轮询间隔且有**别人**发言、会话空闲才汇总成一条用户消息交给每群一个的代答会话(meta `channel_delegate`,无工作区,不建出站投影 —— 普通回复绝不发进群);代答会话只给只读白名单工具 + `send_to_group_chat`(前缀、2000 字、每次汇总 5 条)、MCP 关闭,非代答会话隐藏该工具。@本人只做标记和优先级,同样等轮询。产品相关的名字一律只出现在连接器包里,ChannelBoundaryGuard 守着。回归:`ImChannelPluginEndToEnd.*`(假插件 `tests/channels/helpers/fake_channel_plugin.cpp`)、`PluginTransportTest.*`、`GroupWatch*` / `GroupDelegate.*`、`AgentLoopChannelDelegate.*`。
389+
- **外部平台插件**(openspec add-channel-platform-plugins,协议 [docs/channel-platform-plugin.md](docs/channel-platform-plugin.md)):daemon **只在启动时**扫描 `<data_dir>/connectors/*/channel-platform.json` 注册成平台(装包后要重启),名字不能与内置平台重名,`Address::valid` 的平台名因此只按字符集校验。插件是子进程,stdio JSON lines(`im/plugin/plugin_transport`,退避重启);插件常会拉起自己的常驻服务,服务继承了插件的 stdout 写端,插件退出后管道**不会 EOF** —— 所以 reader 用 `PipedProcess::read_stdout_for` 带超时读,supervisor 每 200ms 查一次插件进程是否已退出,别改回阻塞读等 EOF(停止会卡到那个服务退出,「取消连接」请求挂住,崩溃也发现不了);插件回复 initialize 后马上报状态、推入站,初始化完成前的事件按序缓存、完成后补发(`dispatch_event`),外部看到「已连接」时能力与就绪标记已就位;只支持本人账号 `OwnerBinding::Self`:只收 `sender == account` 的私聊,发送人即机主,没有向导和绑定码,`status` 报需要登录时卡片出「登录」(`POST /api/channels/<p>/login` → `action("login")`)。声明 `watched_groups` 的插件,群消息进 `GroupBuffer`,到轮询间隔且有**别人**发言、会话空闲才汇总成一条用户消息交给每群一个的代答会话(meta `channel_delegate`,无工作区,不建出站投影 —— 普通回复绝不发进群);代答会话只给只读白名单工具 + `send_to_group_chat`(前缀、2000 字、每次汇总 5 条)、MCP 关闭,非代答会话隐藏该工具。@本人只做标记和优先级,同样等轮询。产品相关的名字一律只出现在连接器包里,ChannelBoundaryGuard 守着。回归:`ImChannelPluginEndToEnd.*`(假插件 `tests/channels/helpers/fake_channel_plugin.cpp`)、`PluginTransportTest.*`、`GroupWatch*` / `GroupDelegate.*`、`AgentLoopChannelDelegate.*`。
390390
- 回归:`tests/daemon/im_channels_e2e_test.cpp`(生产装配 + 真实 SessionRegistry / 脚本化模型 + 真实传输层对假平台,覆盖验收清单里不依赖真实账号的部分)、`tests/im/`、`tests/network/websocket_client_test.cpp`、`tests/platform/aes_gcm_test.cpp`、`tests/channels/core/`(核心用内存版 SessionClient 与假传输层,宿主测试含两个宿主争同一把锁;`state_log_test.cpp` 守着日志内容与“不写凭据”)、`tests/web/channels_handler_test.cpp`、`web_server_smoke_test.cpp` 的 `ChannelRoutesRejectNonLocalClients` 等三条,前端 `channelsSettings.test.js` 与 `desktopNotify.test.js`。各平台假服务在 `tests/test_support/im/fake_*_server.hpp`(端到端测试只在用到的用例里创建微信 / 飞书 / 钉钉 / Discord / LINE 假服务,传输层参数经 `ImChannelDeps::tune_services` 调整),所有测试都不连真实平台。
391391

392392
## Desktop Shell

‎src/adapters/im/plugin/plugin_transport.cpp‎

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ namespace {
1515

1616
// 一次子进程稳定运行这么久之后再退出,退避从头开始。
1717
constexpr auto kStableRun = std::chrono::seconds(60);
18+
// reader 每次最多等这么久就回头看一眼是否该退出;supervisor 按这个间隔检查插件进程是否已退出。
19+
constexpr int kReadPollMs = 100;
20+
constexpr auto kExitPoll = std::chrono::milliseconds(200);
21+
// 初始化完成前最多先存这么多事件;正常插件此时只有一两条状态与入站。
22+
constexpr std::size_t kMaxEarlyEvents = 256;
1823

1924
std::string str(const nlohmann::json& object, const char* key) {
2025
if (!object.is_object()) return {};
@@ -220,6 +225,7 @@ bool PluginTransport::run_process_once() {
220225
failure_.clear();
221226
pending_.clear();
222227
writes_.clear();
228+
early_events_.clear();
223229
}
224230
if (!process_.start(spawn, &error)) {
225231
std::lock_guard<std::mutex> lock(io_mu_);
@@ -267,13 +273,22 @@ bool PluginTransport::run_process_once() {
267273
std::lock_guard<std::mutex> lock(io_mu_);
268274
initialized_ = alive_;
269275
}
276+
flush_early_events();
270277
} catch (const std::exception& e) {
271278
fail_process(std::string("插件初始化失败:") + e.what());
272279
}
273280

274-
{
275-
std::unique_lock<std::mutex> lock(io_mu_);
276-
io_cv_.wait(lock, [this] { return !alive_ || stopping_.load(); });
281+
// 插件拉起的常驻子进程会继承 stdout 写端,插件退出后管道不一定 EOF;
282+
// 所以这里周期性地看插件进程本身是否已退出。
283+
for (;;) {
284+
{
285+
std::unique_lock<std::mutex> lock(io_mu_);
286+
if (io_cv_.wait_for(lock, kExitPoll, [this] { return !alive_ || stopping_.load(); })) break;
287+
}
288+
if (process_.wait_exit(0)) {
289+
fail_process("插件进程已退出");
290+
break;
291+
}
277292
}
278293
if (stopping_.load()) {
279294
// 正常关闭:先请插件自己收尾,再关 stdin,限时等它退出。
@@ -318,7 +333,9 @@ void PluginTransport::read_loop() {
318333
std::lock_guard<std::mutex> lock(io_mu_);
319334
if (!alive_) break;
320335
}
321-
const auto count = process_.read_stdout(bytes, sizeof(bytes));
336+
bool timed_out = false;
337+
const auto count = process_.read_stdout_for(bytes, sizeof(bytes), kReadPollMs, &timed_out);
338+
if (timed_out) continue; // 回到循环顶部检查进程是否已被判定退出
322339
if (count <= 0) {
323340
fail_process("插件进程已退出");
324341
break;
@@ -392,7 +409,44 @@ void PluginTransport::handle_frame(const nlohmann::json& frame) {
392409
}
393410
const auto event = str(frame, "event");
394411
if (event.empty()) throw std::runtime_error("unrecognized frame");
395-
handle_event(event, frame.value("params", nlohmann::json::object()));
412+
dispatch_event(event, frame.value("params", nlohmann::json::object()));
413+
}
414+
415+
void PluginTransport::dispatch_event(const std::string& event, const nlohmann::json& params) {
416+
// 插件回复 initialize 后紧接着就报状态、推入站;supervisor 那时可能还没处理完 initialize
417+
// 结果(能力、就绪标记)。初始化完成前的事件按到达顺序先存着,完成后再依次处理,外部看到
418+
// 「已连接」时能力与就绪状态一定已经就位。event_mu_ 让补发与后来的事件不会交错。
419+
std::lock_guard<std::mutex> order(event_mu_);
420+
std::vector<std::pair<std::string, nlohmann::json>> backlog;
421+
{
422+
std::lock_guard<std::mutex> lock(io_mu_);
423+
if (!initialized_) {
424+
if (early_events_.size() < kMaxEarlyEvents) early_events_.emplace_back(event, params);
425+
return;
426+
}
427+
backlog.swap(early_events_);
428+
}
429+
for (const auto& [name, early] : backlog) handle_event_logged(name, early);
430+
handle_event(event, params);
431+
}
432+
433+
void PluginTransport::flush_early_events() {
434+
std::lock_guard<std::mutex> order(event_mu_);
435+
std::vector<std::pair<std::string, nlohmann::json>> backlog;
436+
{
437+
std::lock_guard<std::mutex> lock(io_mu_);
438+
if (!initialized_) return;
439+
backlog.swap(early_events_);
440+
}
441+
for (const auto& [name, early] : backlog) handle_event_logged(name, early);
442+
}
443+
444+
void PluginTransport::handle_event_logged(const std::string& event, const nlohmann::json& params) {
445+
try {
446+
handle_event(event, params);
447+
} catch (const std::exception& e) {
448+
LOG_WARN("[channels/" + options_.platform + "] plugin event rejected: " + e.what());
449+
}
396450
}
397451

398452
void PluginTransport::handle_event(const std::string& event, const nlohmann::json& params) {

‎src/adapters/im/plugin/plugin_transport.hpp‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ class PluginTransport final : public Transport {
8585
void write_loop();
8686
void handle_frame(const nlohmann::json& frame);
8787
void handle_event(const std::string& event, const nlohmann::json& params);
88+
void dispatch_event(const std::string& event, const nlohmann::json& params);
89+
void flush_early_events();
90+
void handle_event_logged(const std::string& event, const nlohmann::json& params);
8891
void fail_process(const std::string& reason);
8992
// 失败时抛 std::runtime_error(消息可直接展示)。
9093
nlohmann::json request(const std::string& method, const nlohmann::json& params, std::chrono::milliseconds timeout);
@@ -114,6 +117,8 @@ class PluginTransport final : public Transport {
114117
std::uint64_t next_id_ = 1;
115118
std::map<std::uint64_t, std::shared_ptr<Pending>> pending_;
116119
std::deque<std::string> writes_;
120+
std::vector<std::pair<std::string, nlohmann::json>> early_events_; // 初始化完成前到达的事件
121+
std::mutex event_mu_; // 串行化事件处理(reader 与初始化后的补发)
117122

118123
mutable std::mutex mu_; // status_、capabilities_、settings_
119124
TransportStatus status_;

‎src/base/platform/process/piped_process.cpp‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55

66
#include <algorithm>
77
#include <cctype>
8+
#include <chrono>
89
#include <cstring>
910
#include <map>
11+
#include <thread>
1012

1113
#ifdef _WIN32
1214
#ifndef WIN32_LEAN_AND_MEAN
@@ -18,8 +20,8 @@
1820
#include <windows.h>
1921
#else
2022
#include <cerrno>
21-
#include <chrono>
2223
#include <fcntl.h>
24+
#include <poll.h>
2325
#include <signal.h>
2426
#include <stdlib.h>
2527
#include <sys/wait.h>
@@ -314,6 +316,44 @@ long PipedProcess::read_stdout(char* buf, std::size_t len) {
314316
#endif
315317
}
316318

319+
long PipedProcess::read_stdout_for(char* buf, std::size_t len, int timeout_ms, bool* timed_out) {
320+
if (timed_out) *timed_out = false;
321+
if (!stdout_read_) return 0;
322+
#ifdef _WIN32
323+
// 匿名管道没有带超时的同步读;先看管道里有没有数据,没有就短睡再看。
324+
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
325+
auto nap = std::chrono::milliseconds(2);
326+
for (;;) {
327+
DWORD available = 0;
328+
if (!PeekNamedPipe(stdout_read_.get(), nullptr, 0, nullptr, &available, nullptr)) {
329+
const DWORD err = GetLastError();
330+
if (err == ERROR_BROKEN_PIPE || err == ERROR_HANDLE_EOF) return 0;
331+
return -1;
332+
}
333+
if (available > 0) return read_stdout(buf, std::min<std::size_t>(len, available));
334+
const auto now = std::chrono::steady_clock::now();
335+
if (now >= deadline) {
336+
if (timed_out) *timed_out = true;
337+
return 0;
338+
}
339+
std::this_thread::sleep_for(std::min<std::chrono::steady_clock::duration>(nap, deadline - now));
340+
nap = std::min(nap * 2, std::chrono::milliseconds(50));
341+
}
342+
#else
343+
pollfd fd{stdout_read_.get(), POLLIN, 0};
344+
for (;;) {
345+
const int ready = ::poll(&fd, 1, timeout_ms);
346+
if (ready < 0 && errno == EINTR) continue;
347+
if (ready < 0) return -1;
348+
if (ready == 0) {
349+
if (timed_out) *timed_out = true;
350+
return 0;
351+
}
352+
return read_stdout(buf, len);
353+
}
354+
#endif
355+
}
356+
317357
bool PipedProcess::write_stdin(const char* data, std::size_t len, std::string* error) {
318358
#ifdef _WIN32
319359
if (!stdin_write_) {

‎src/base/platform/process/piped_process.hpp‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class PipedProcess {
4141
// 阻塞读 stdout。>0 = 读到的字节数;0 = EOF(子进程退出/管道关闭);
4242
// <0 = 读错误。只允许单一 reader 线程调用。
4343
long read_stdout(char* buf, std::size_t len);
44+
// 同 read_stdout,但最多等 timeout_ms:没有数据时返回 0 并置 *timed_out = true。
45+
// 子进程拉起的常驻孙进程可能继承 stdout 写端,子进程退出后管道也不会 EOF;
46+
// 需要能按自己的停止条件退出的 reader 用这个。
47+
long read_stdout_for(char* buf, std::size_t len, int timeout_ms, bool* timed_out);
4448

4549
bool write_stdin(const char* data, std::size_t len, std::string* error);
4650
// 关闭 stdin 写端(LSP exit 之后让 server 观察到 EOF)。

‎tests/channels/helpers/fake_channel_plugin.cpp‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,25 @@
99
// --version <N> initialize 报告的协议版本(默认 1)
1010
// --login-required 初始化后报告需要登录;收到 action login 后再报告已连接
1111
// --no-inbound 初始化后不主动发入站消息
12+
// --spawn-holder <毫秒> 启动时拉起一个继承 stdin/stdout 的子进程,睡这么久再退出
13+
// (模拟插件拉起的常驻服务:插件退出后管道写端仍被它持有)
14+
// --hold <毫秒> 只睡这么久就退出,不读写任何东西(--spawn-holder 拉起的子进程用)
1215

1316
#include <nlohmann/json.hpp>
1417

18+
#include <chrono>
1519
#include <cstdio>
20+
#include <filesystem>
1621
#include <fstream>
1722
#include <iostream>
1823
#include <string>
24+
#include <thread>
1925
#ifdef _WIN32
2026
#include <fcntl.h>
2127
#include <io.h>
28+
#include <process.h>
29+
#else
30+
#include <unistd.h>
2231
#endif
2332

2433
namespace {
@@ -34,8 +43,24 @@ struct Options {
3443
int version = 1;
3544
bool login_required = false;
3645
bool inbound = true;
46+
std::string spawn_holder_ms;
3747
};
3848

49+
// 拉起一个继承标准句柄的子进程;它不碰管道,只是让写端在插件退出后仍然打开。
50+
void spawn_holder(const char* self, const std::string& ms) {
51+
#ifdef _WIN32
52+
// _spawnv 按空格拼命令行,程序路径要自己加引号。
53+
const std::string quoted = std::string("\"") + self + "\"";
54+
const char* args[] = {quoted.c_str(), "--hold", ms.c_str(), nullptr};
55+
_spawnv(_P_NOWAIT, self, args);
56+
#else
57+
if (::fork() == 0) {
58+
::execl(self, self, "--hold", ms.c_str(), static_cast<char*>(nullptr));
59+
::_exit(127);
60+
}
61+
#endif
62+
}
63+
3964
void emit(const json& frame) {
4065
// argv 在 Windows 上按 ANSI 代码页解码,中文参数可能不是合法 UTF-8;替换掉而不是让 dump 抛异常退出。
4166
std::cout << frame.dump(-1, ' ', false, json::error_handler_t::replace) << '\n';
@@ -106,7 +131,16 @@ int main(int argc, char** argv) {
106131
else if (arg == "--version") options.version = std::stoi(next());
107132
else if (arg == "--login-required") options.login_required = true;
108133
else if (arg == "--no-inbound") options.inbound = false;
134+
else if (arg == "--spawn-holder") options.spawn_holder_ms = next();
135+
else if (arg == "--hold") {
136+
// 换到临时目录再睡:继承来的 cwd 是测试的临时目录,占着它测试就删不掉。
137+
std::error_code ec;
138+
std::filesystem::current_path(std::filesystem::temp_directory_path(ec), ec);
139+
std::this_thread::sleep_for(std::chrono::milliseconds(std::stoi(next())));
140+
return 0;
141+
}
109142
}
143+
if (!options.spawn_holder_ms.empty()) spawn_holder(argv[0], options.spawn_holder_ms);
110144
const int started = start_count(options);
111145

112146
std::string line;

‎tests/im/plugin_transport_test.cpp‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,29 @@ TEST_F(PluginTransportTest, StopSendsShutdownAndReturnsPromptly) {
212212
EXPECT_TRUE(logged(dir / "frames.log", "shutdown"));
213213
}
214214

215+
// 场景:插件启动时拉起了一个常驻子进程(真实插件会拉起自己的后台服务),子进程继承了插件的
216+
// stdout 写端;然后用户点「取消连接」。
217+
// 期望:插件响应 shutdown 退出后 stop 就返回,不等那个子进程。
218+
// 回归:reader 一直阻塞在读 stdout 上等 EOF,而写端还在子进程手里,stop 卡到子进程退出为止;
219+
// 真进程联调时「取消连接」的请求挂了两分钟以上。子进程睡 15 秒,远大于 5 秒的断言门槛。
220+
TEST_F(PluginTransportTest, StopDoesNotWaitForAChildProcessHoldingThePipe) {
221+
start({"--no-inbound", "--spawn-holder", "15000"});
222+
ASSERT_TRUE(wait_until([this] { return transport->status().state == LinkState::Connected; }));
223+
const auto began = std::chrono::steady_clock::now();
224+
transport->stop();
225+
EXPECT_LT(std::chrono::steady_clock::now() - began, std::chrono::seconds(5));
226+
EXPECT_EQ(transport->status().state, LinkState::Stopped);
227+
}
228+
229+
// 场景:插件拉起常驻子进程后崩溃退出(回复 initialize 后立刻退出),子进程仍持有 stdout 写端。
230+
// 期望:传输层照样发现插件退出并重启,第二次启动正常连接。
231+
// 回归:只靠管道 EOF 判断退出时,崩溃要等子进程也退出才被发现,期间通道一直显示已连接却收发不了。
232+
TEST_F(PluginTransportTest, CrashIsDetectedWhileAChildProcessHoldsThePipe) {
233+
start({"--crash-times", "1", "--no-inbound", "--spawn-holder", "15000"});
234+
ASSERT_TRUE(wait_until([this] { return start_count() >= 2 && transport->status().state == LinkState::Connected; }));
235+
EXPECT_EQ(start_count(), 2);
236+
}
237+
215238
// 场景:插件给的入站地址不合法(私聊 chat 与 sender 不同、缺 message_id)。
216239
// 期望:解析拒绝,不交给核心。
217240
TEST(PluginInboundParse, RejectsInvalidAddresses) {

0 commit comments

Comments
 (0)