40 函数级剖析:启动侧车与优雅关停
核心文件:
src/gateway/server-startup.tssrc/gateway/server-close.tssrc/infra/restart-sentinel.tssrc/gateway/server-restart-sentinel.ts
模块定位
startGatewaySidecars 以固定顺序启动所有外围服务,createGatewayCloseHandler 以精确逆序关停所有组件,restart-sentinel 保证重启前后的用户上下文连续性。
一、startGatewaySidecars(9 步启动顺序)
ts
// src/gateway/server-startup.ts
export async function startGatewaySidecars(params: {
cfg: ReturnType<typeof loadConfig>;
pluginRegistry: ReturnType<typeof loadOpenClawPlugins>;
defaultWorkspaceDir: string;
deps: CliDeps;
startChannels: () => Promise<void>;
log: { warn: (msg: string) => void };
logHooks: { info, warn, error };
logChannels: { info, error };
logBrowser: { error };
})精确启动顺序(9 步):
步骤 1: startBrowserControlServerIfEnabled()
步骤 2: startGmailWatcher(params.cfg) [跳过条件: OPENCLAW_SKIP_GMAIL_WATCHER=1]
步骤 3: loadModelCatalog / getModelRefStatus (hooks.gmail.model 校验)
步骤 4: clearInternalHooks() + loadInternalHooks(params.cfg, ...)
步骤 5: params.startChannels() [跳过条件: OPENCLAW_SKIP_CHANNELS=1]
步骤 6: triggerInternalHook("gateway:startup", ...) [setTimeout 250ms 延迟]
步骤 7: startPluginServices({ registry, config, workspaceDir })
步骤 8: startGatewayMemoryBackend({ cfg, log }) [void,不阻塞]
步骤 9: scheduleRestartSentinelWake({ deps }) [setTimeout 750ms,若 shouldWakeFromRestartSentinel()]设计思路: 主网关只负责核心路由,外围能力作为"侧车"启动,每个能力可以独立失败、独立重启,不会把主链完全绑死。
二、restart sentinel 完整 Schema
ts
// src/infra/restart-sentinel.ts
export type RestartSentinel = {
version: 1; // 固定值,用于格式版本校验
payload: RestartSentinelPayload;
};
export type RestartSentinelPayload = {
kind: "config-apply" | "config-patch" | "update" | "restart";
status: "ok" | "error" | "skipped";
ts: number; // 时间戳(Unix ms)
sessionKey?: string; // 触发重启的会话 key
deliveryContext?: { // 通道路由信息(重启前捕获)
channel?: string;
to?: string;
accountId?: string;
};
threadId?: string; // 回复线程 ID(如 Slack thread_ts)
message?: string | null; // 用户可读的结果消息
doctorHint?: string | null; // 诊断提示(如 "Run: openclaw doctor")
stats?: RestartSentinelStats | null;
};
export type RestartSentinelStats = {
mode?: string;
root?: string;
before?: Record<string, unknown> | null;
after?: Record<string, unknown> | null;
steps?: RestartSentinelStep[];
reason?: string | null;
durationMs?: number | null;
};
export type RestartSentinelStep = {
name: string;
command: string;
cwd?: string | null;
durationMs?: number | null;
log?: RestartSentinelLog | null;
};
export type RestartSentinelLog = {
stdoutTail?: string | null; // 最多 8000 chars(trimLogTail 截断)
stderrTail?: string | null;
exitCode?: number | null;
};落盘文件名: restart-sentinel.json(常量 SENTINEL_FILENAME) 路径: resolveStateDir(env) + "/restart-sentinel.json"
三、restart sentinel 生命周期
重启前落盘(writeRestartSentinel)
│
▼ 进程退出 → 新进程启动
│
▼
步骤 9: scheduleRestartSentinelWake(setTimeout 750ms)
│
▼
consumeRestartSentinel() // 读取并删除文件(原子消费)
│
├─ 解析成功 → 尝试 outbound 发送结果到 deliveryContext.channel
│
└─ 发送失败 → 回落 system event(不丢失)版本校验:
ts
export async function readRestartSentinel(env): Promise<RestartSentinel | null> {
// ...
if (!parsed || parsed.version !== 1 || !parsed.payload) {
await fs.unlink(filePath).catch(() => {}); // 格式错误,删除无效文件
return null;
}
return parsed;
}四、createGatewayCloseHandler(20 步关停顺序)
ts
// src/gateway/server-close.ts
// 精确关停顺序:
步骤 1: params.bonjourStop() // Bonjour mDNS 广播
步骤 2: params.tailscaleCleanup() // Tailscale 清理
步骤 3: params.canvasHost.close() // Canvas Host handler
步骤 4: params.canvasHostServer.close() // Canvas Host server
步骤 5: for (plugin of listChannelPlugins()) { await stopChannel(plugin.id); } // 所有频道
步骤 6: params.pluginServices.stop() // 插件服务
步骤 7: stopGmailWatcher() // Gmail watcher
步骤 8: params.cron.stop() // Cron
步骤 9: params.heartbeatRunner.stop() // 心跳
步骤 10: for (timer of nodePresenceTimers) { clearInterval(timer); }
步骤 11: params.broadcast("shutdown", { reason, restartExpectedMs }) // 广播 shutdown 事件
步骤 12: clearInterval(params.tickInterval)
clearInterval(params.healthInterval)
clearInterval(params.dedupeCleanup)
步骤 13: params.agentUnsub()
params.heartbeatUnsub()
步骤 14: params.chatRunState.clear()
步骤 15: for (c of params.clients) { // 关闭所有 WS 客户端
c.socket.close(1012, "service restart");
}
步骤 16: params.clients.clear()
步骤 17: params.configReloader.stop()
步骤 18: params.browserControl.stop()
步骤 19: await new Promise<void>((resolve) => params.wss.close(() => resolve())) // WebSocket Server
步骤 20: httpServer.closeIdleConnections?.()
httpServer.close(...) // HTTP Server(可能多个)关停逻辑顺序原则:
- 先停"发现/暴露"(Bonjour、Tailscale)
- 停"产生新任务"的入口(channels、plugins)
- 停"周期性任务"(cron、heartbeat)
- 广播 shutdown(告知客户端)
- 关闭存量连接(WS clients)
- 关闭监听(wss、http servers)
五、WS close code 1012
ts
// src/gateway/server-close.ts 行 80-84
for (const c of params.clients) {
try {
c.socket.close(1012, "service restart");
} catch {
/* ignore */
}
}为什么用 1012: HTTP 标准状态码"Service Restart",客户端可据此做"服务重启中"的重连策略(而非当成异常断开)。
六、formatRestartSentinelMessage(消息格式化)
ts
// src/infra/restart-sentinel.ts
export function formatRestartSentinelMessage(payload: RestartSentinelPayload): string {
if (payload.message?.trim()) {
return payload.message.trim(); // 优先使用自定义消息
}
return summarizeRestartSentinel(payload); // 回退到自动摘要
}
export function summarizeRestartSentinel(payload: RestartSentinelPayload): string {
const kind = payload.kind;
const status = payload.status;
const mode = payload.stats?.mode ? ` (${payload.stats.mode})` : "";
return `Gateway restart ${kind} ${status}${mode}`.trim();
// 例如: "Gateway restart update ok (hybrid)"
}七、trimLogTail(日志尾部截断)
ts
// src/infra/restart-sentinel.ts
export function trimLogTail(input?: string | null, maxChars = 8000) {
if (!input) { return null; }
const text = input.trimEnd();
if (text.length <= maxChars) { return text; }
return `…${text.slice(text.length - maxChars)}`; // 保留最后 8000 chars
}八、自检清单
startGatewaySidecars步骤 9 是setTimeout 750ms,不阻塞主启动流程。consumeRestartSentinel读取后立刻删除文件(防重复消费),即使后续处理失败。version !== 1的文件直接删除,不尝试兼容旧格式。- 关停步骤 11 广播 shutdown 时 clients 还未 close,客户端能收到通知后主动断连。
httpServer.closeIdleConnections?.()是可选调用(?.),Node.js 18+ 才有此方法。
九、开发避坑
- sentinal 750ms 延迟是故意的:确保通道和 agent 系统初始化完成后再发送重启结果消息。
deliveryContext必须在重启前捕获:重启后 session 上下文可能已不存在,靠 sentinel 保证路由。- 关停步骤顺序不可随意调换:先停 channels 再停 cron,否则 cron 任务可能在 channel 已停时仍尝试发送。
broadcast("shutdown")不保证送达:WS 是异步的,客户端可能来不及处理就被 close(1012) 切断。