Skip to content

40 函数级剖析:启动侧车与优雅关停

核心文件:

  • src/gateway/server-startup.ts
  • src/gateway/server-close.ts
  • src/infra/restart-sentinel.ts
  • src/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(可能多个)

关停逻辑顺序原则:

  1. 先停"发现/暴露"(Bonjour、Tailscale)
  2. 停"产生新任务"的入口(channels、plugins)
  3. 停"周期性任务"(cron、heartbeat)
  4. 广播 shutdown(告知客户端)
  5. 关闭存量连接(WS clients)
  6. 关闭监听(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
}

八、自检清单

  1. startGatewaySidecars 步骤 9 是 setTimeout 750ms,不阻塞主启动流程。
  2. consumeRestartSentinel 读取后立刻删除文件(防重复消费),即使后续处理失败。
  3. version !== 1 的文件直接删除,不尝试兼容旧格式。
  4. 关停步骤 11 广播 shutdown 时 clients 还未 close,客户端能收到通知后主动断连。
  5. httpServer.closeIdleConnections?.() 是可选调用(?.),Node.js 18+ 才有此方法。

九、开发避坑

  1. sentinal 750ms 延迟是故意的:确保通道和 agent 系统初始化完成后再发送重启结果消息。
  2. deliveryContext 必须在重启前捕获:重启后 session 上下文可能已不存在,靠 sentinel 保证路由。
  3. 关停步骤顺序不可随意调换:先停 channels 再停 cron,否则 cron 任务可能在 channel 已停时仍尝试发送。
  4. broadcast("shutdown") 不保证送达:WS 是异步的,客户端可能来不及处理就被 close(1012) 切断。

用工程视角拆解 AI 智能体框架