chatccc 0.2.208 → 0.2.209

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.208",
3
+ "version": "0.2.209",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  INTERNAL_RESTART_ENV_VAR,
5
5
  buildWebUiUrl,
6
+ createServiceLifecycleGuard,
6
7
  createInternalRestartEnv,
7
8
  openWebUiInDefaultBrowser,
8
9
  shouldAutoOpenWebUi,
@@ -96,3 +97,135 @@ describe("ChatCCC startup lifecycle", () => {
96
97
  expect(onInfo).toHaveBeenCalledWith(expect.stringContaining("http://localhost:18080/"));
97
98
  });
98
99
  });
100
+
101
+ describe("ChatCCC service lifecycle guard", () => {
102
+ function createHarness() {
103
+ let intervalCallback: (() => void) | undefined;
104
+ const timer = { ref: vi.fn() };
105
+ const setIntervalImpl = vi.fn((callback: () => void) => {
106
+ intervalCallback = callback;
107
+ return timer;
108
+ });
109
+ const clearIntervalImpl = vi.fn();
110
+ const tracer = vi.fn();
111
+ const server = {
112
+ listening: true,
113
+ address: vi.fn(() => ({ address: "127.0.0.1", port: 18080 })),
114
+ ref: vi.fn(),
115
+ };
116
+ const recoverServer = vi.fn(async () => {
117
+ server.listening = true;
118
+ });
119
+ const guard = createServiceLifecycleGuard({
120
+ intervalMs: 10_000,
121
+ setIntervalImpl,
122
+ clearIntervalImpl,
123
+ tracer,
124
+ getActiveResourcesInfo: () => ["TCPServerWrap", "Timeout"],
125
+ });
126
+
127
+ return {
128
+ clearIntervalImpl,
129
+ getIntervalCallback: () => intervalCallback,
130
+ guard,
131
+ recoverServer,
132
+ server,
133
+ setIntervalImpl,
134
+ timer,
135
+ tracer,
136
+ };
137
+ }
138
+
139
+ it("starts exactly one referenced keep-alive timer", () => {
140
+ const h = createHarness();
141
+
142
+ h.guard.start();
143
+ h.guard.start();
144
+
145
+ expect(h.setIntervalImpl).toHaveBeenCalledOnce();
146
+ expect(h.setIntervalImpl).toHaveBeenCalledWith(expect.any(Function), 10_000);
147
+ expect(h.timer.ref).toHaveBeenCalledOnce();
148
+ });
149
+
150
+ it("keeps a listening HTTP server referenced", async () => {
151
+ const h = createHarness();
152
+ h.guard.attachServer(h.server, h.recoverServer);
153
+ h.guard.start();
154
+
155
+ await h.guard.checkNow();
156
+
157
+ expect(h.server.ref).toHaveBeenCalled();
158
+ expect(h.recoverServer).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it("coalesces concurrent recovery when the HTTP server is not listening", async () => {
162
+ const h = createHarness();
163
+ h.server.listening = false;
164
+ let finishRecovery: (() => void) | undefined;
165
+ h.recoverServer.mockImplementation(() => new Promise<void>((resolve) => {
166
+ finishRecovery = () => {
167
+ h.server.listening = true;
168
+ resolve();
169
+ };
170
+ }));
171
+ h.guard.attachServer(h.server, h.recoverServer);
172
+ h.guard.start();
173
+
174
+ const first = h.guard.checkNow();
175
+ const second = h.guard.checkNow();
176
+ await Promise.resolve();
177
+ expect(h.recoverServer).toHaveBeenCalledOnce();
178
+
179
+ finishRecovery?.();
180
+ await Promise.all([first, second]);
181
+ expect(h.tracer).toHaveBeenCalledWith(
182
+ "service-lifecycle: HTTP server recovered",
183
+ expect.objectContaining({ serverListening: true }),
184
+ );
185
+ });
186
+
187
+ it("re-arms on unexpected beforeExit and records public diagnostics", async () => {
188
+ const h = createHarness();
189
+ h.guard.attachServer(h.server, h.recoverServer);
190
+
191
+ h.guard.handleBeforeExit(0);
192
+ await h.guard.checkNow();
193
+
194
+ expect(h.setIntervalImpl).toHaveBeenCalledOnce();
195
+ expect(h.server.ref).toHaveBeenCalled();
196
+ expect(h.tracer).toHaveBeenCalledWith(
197
+ "service-lifecycle: unexpected beforeExit",
198
+ expect.objectContaining({
199
+ code: 0,
200
+ activeResources: ["TCPServerWrap", "Timeout"],
201
+ serverListening: true,
202
+ }),
203
+ );
204
+ });
205
+
206
+ it("stays stopped after an intentional shutdown", () => {
207
+ const h = createHarness();
208
+ h.guard.start();
209
+
210
+ h.guard.beginShutdown("SIGTERM");
211
+ h.guard.handleBeforeExit(0);
212
+
213
+ expect(h.clearIntervalImpl).toHaveBeenCalledWith(h.timer);
214
+ expect(h.setIntervalImpl).toHaveBeenCalledOnce();
215
+ expect(h.tracer).toHaveBeenCalledWith(
216
+ "service-lifecycle: shutdown requested",
217
+ { reason: "SIGTERM" },
218
+ );
219
+ });
220
+
221
+ it("the timer callback runs a health check", async () => {
222
+ const h = createHarness();
223
+ h.guard.attachServer(h.server, h.recoverServer);
224
+ h.guard.start();
225
+
226
+ h.getIntervalCallback()?.();
227
+ await Promise.resolve();
228
+
229
+ expect(h.server.ref).toHaveBeenCalled();
230
+ });
231
+ });
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRel
31
31
  import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
32
32
  import {
33
33
  buildWebUiUrl,
34
+ createServiceLifecycleGuard,
34
35
  openWebUiInDefaultBrowser,
35
36
  shouldAutoOpenWebUi,
36
37
  } from "./startup-lifecycle.ts";
@@ -744,10 +745,19 @@ async function main(): Promise<void> {
744
745
  autoOpenWebUi,
745
746
  });
746
747
 
747
- // 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
748
+ // ChatCCC 是常驻服务,不能把“当前刚好有没有 Agent session”当作进程生命周期。
749
+ // referenced timer 明确锚定服务进程;HTTP Server 接入后还会定期 ref/健康检查。
750
+ // `/restart`、`/update` 都使用 process.exit(),不会被 timer 阻止。
751
+ const serviceLifecycle = createServiceLifecycleGuard({ tracer: appendStartupTrace });
752
+ serviceLifecycle.start();
753
+
754
+ // 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
748
755
  // 越早装越好——后续任何一行抛错都有兜底;它独立于 SIGINT 清理(见末尾的
749
756
  // server.close)——只负责诊断与默认致命退出,不替代清理逻辑。
750
- installCrashLogging({ flush: () => fileLog.flush() });
757
+ installCrashLogging({
758
+ flush: () => fileLog.flush(),
759
+ onBeforeExit: (code) => serviceLifecycle.handleBeforeExit(code),
760
+ });
751
761
 
752
762
  // 模拟模式:独立端口 18079,不与 SDK 实例冲突,不走飞书凭证/权限/WSClient
753
763
  if (USE_SIMULATE) {
@@ -787,10 +797,14 @@ async function main(): Promise<void> {
787
797
  simServer.once("error", onError);
788
798
  simServer.once("listening", onListening);
789
799
  simServer.listen(SIM_PORT, "127.0.0.1");
790
- }).catch((err: NodeJS.ErrnoException) => {
800
+ }).catch((err: NodeJS.ErrnoException) => {
791
801
  console.error(`\n[启动] 监听失败:端口 ${SIM_PORT}(${err.code ?? "?"} — ${err.message})`);
792
- process.exit(1);
793
- });
802
+ process.exit(1);
803
+ });
804
+ serviceLifecycle.attachServer(
805
+ simServer,
806
+ () => recoverHttpServer(simServer, SIM_PORT),
807
+ );
794
808
 
795
809
  console.log(`\n${"=".repeat(60)}`);
796
810
  console.log(` ChatCCC — 模拟飞书环境模式`);
@@ -808,8 +822,8 @@ async function main(): Promise<void> {
808
822
  );
809
823
  }
810
824
 
811
- installShutdownHandlers(simServer);
812
- return;
825
+ installShutdownHandlers(simServer, serviceLifecycle);
826
+ return;
813
827
  }
814
828
 
815
829
  if (Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535) {
@@ -856,7 +870,7 @@ async function main(): Promise<void> {
856
870
  if (!APP_ID.trim() || !APP_SECRET.trim()) {
857
871
  // 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
858
872
  // 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
859
- startSetupMode(CHATCCC_PORT, {
873
+ const setupServer = startSetupMode(CHATCCC_PORT, {
860
874
  openBrowser: autoOpenWebUi,
861
875
  onActivate: async (httpServer: Server) => {
862
876
  reloadRuntimeConfig("setup-activate");
@@ -865,17 +879,23 @@ async function main(): Promise<void> {
865
879
  });
866
880
  try {
867
881
  await startConfiguredPlatforms(httpServer, { failOnFeishuError: true });
868
- installShutdownHandlers(httpServer);
869
- return { ok: true };
882
+ return { ok: true };
870
883
  } catch (err) {
871
884
  appendStartupTrace("setup-activate: startConfiguredPlatforms failed", {
872
885
  message: (err as Error).message,
873
886
  });
874
887
  return { ok: false, error: (err as Error).message };
875
888
  }
876
- },
877
- });
878
- return;
889
+ },
890
+ });
891
+ setupServer.once("listening", () => {
892
+ serviceLifecycle.attachServer(
893
+ setupServer,
894
+ () => recoverHttpServer(setupServer, CHATCCC_PORT),
895
+ );
896
+ });
897
+ installShutdownHandlers(setupServer, serviceLifecycle);
898
+ return;
879
899
  }
880
900
  console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
881
901
  appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
@@ -903,6 +923,13 @@ async function main(): Promise<void> {
903
923
  printServiceDidNotStart(`本地中继端口 ${CHATCCC_PORT} 无法监听(${err.code ?? "?"} — ${err.message})`);
904
924
  process.exit(1);
905
925
  });
926
+ serviceLifecycle.attachServer(
927
+ httpServer,
928
+ () => recoverHttpServer(httpServer, CHATCCC_PORT),
929
+ );
930
+ // 平台鉴权/长连接启动可能耗时;此时也必须允许 Ctrl+C / SIGTERM 正常退出,
931
+ // 不能只留下更早安装的信号日志 listener 把默认退出行为吞掉。
932
+ installShutdownHandlers(httpServer, serviceLifecycle);
906
933
 
907
934
  // 必须等 HTTP server 真正监听后再发起打开请求,避免浏览器先到一步看到
908
935
  // ERR_CONNECTION_REFUSED。Chrome CDP 守护仍保持自己原有的独立行为。
@@ -923,9 +950,23 @@ async function main(): Promise<void> {
923
950
  }
924
951
 
925
952
  await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
926
-
927
- installShutdownHandlers(httpServer);
928
- }
953
+ }
954
+
955
+ /**
956
+ * 生命周期健康检查发现 HTTP Server 已停止监听时,优先原地恢复同一个 Server。
957
+ * router、WebSocket upgrade listener 都挂在这个对象上,复用它能保留现有服务绑定;
958
+ * 恢复失败会被 lifecycle guard 记录,并在下一轮检查重试。
959
+ */
960
+ async function recoverHttpServer(httpServer: Server, port: number): Promise<void> {
961
+ if (httpServer.listening) {
962
+ httpServer.ref();
963
+ return;
964
+ }
965
+ appendStartupTrace("service-lifecycle: HTTP recovery begin", { port });
966
+ await listenWithRetry(httpServer, port, "127.0.0.1");
967
+ httpServer.ref();
968
+ appendStartupTrace("service-lifecycle: HTTP recovery listen succeeded", { port });
969
+ }
929
970
 
930
971
  /**
931
972
  * 带重试的 server.listen,Windows 端口释放有延迟时自动重试。
@@ -963,10 +1004,26 @@ async function listenWithRetry(
963
1004
  * Node EventEmitter 按注册顺序触发,installCrashLogging 装得更早 → 同步 trace
964
1005
  * 先写盘,再走这里。
965
1006
  */
966
- function installShutdownHandlers(httpServer: Server): void {
967
- process.on("SIGINT", () => { console.log("\nShutting down..."); wechatSignal.stopped = true; stopChromeDevtoolsGuard(); httpServer.close(); process.exit(0); });
968
- process.on("SIGTERM", () => { wechatSignal.stopped = true; stopChromeDevtoolsGuard(); httpServer.close(); process.exit(0); });
969
- }
1007
+ function installShutdownHandlers(
1008
+ httpServer: Server,
1009
+ serviceLifecycle: ReturnType<typeof createServiceLifecycleGuard>,
1010
+ ): void {
1011
+ process.on("SIGINT", () => {
1012
+ console.log("\nShutting down...");
1013
+ serviceLifecycle.beginShutdown("SIGINT");
1014
+ wechatSignal.stopped = true;
1015
+ stopChromeDevtoolsGuard();
1016
+ httpServer.close();
1017
+ process.exit(0);
1018
+ });
1019
+ process.on("SIGTERM", () => {
1020
+ serviceLifecycle.beginShutdown("SIGTERM");
1021
+ wechatSignal.stopped = true;
1022
+ stopChromeDevtoolsGuard();
1023
+ httpServer.close();
1024
+ process.exit(0);
1025
+ });
1026
+ }
970
1027
 
971
1028
  main().catch((err: Error) => {
972
1029
  appendStartupTrace("main: catch fatal", { message: err.message, stack: err.stack?.slice(0, 800) });
@@ -1,5 +1,159 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
 
3
+ const DEFAULT_SERVICE_HEALTH_INTERVAL_MS = 10_000;
4
+
5
+ interface RefTimer {
6
+ ref?: () => unknown;
7
+ }
8
+
9
+ export interface ServiceLifecycleServer {
10
+ listening: boolean;
11
+ address: () => unknown;
12
+ ref: () => unknown;
13
+ }
14
+
15
+ interface ServiceLifecycleGuardOptions {
16
+ intervalMs?: number;
17
+ setIntervalImpl?: (callback: () => void, delayMs: number) => RefTimer;
18
+ clearIntervalImpl?: (timer: RefTimer) => void;
19
+ tracer?: (message: string, extra?: Record<string, unknown>) => void;
20
+ getActiveResourcesInfo?: () => string[];
21
+ }
22
+
23
+ export interface ServiceLifecycleGuard {
24
+ start: () => void;
25
+ attachServer: (
26
+ server: ServiceLifecycleServer,
27
+ recoverServer?: () => void | Promise<void>,
28
+ ) => void;
29
+ checkNow: () => Promise<void>;
30
+ handleBeforeExit: (code: number) => void;
31
+ beginShutdown: (reason: string) => void;
32
+ }
33
+
34
+ /**
35
+ * 为 ChatCCC 这种常驻服务建立一个明确的进程生命周期锚点。
36
+ *
37
+ * 正常情况下,正在 listen 的 HTTP Server 自己就足以维持事件循环;额外的
38
+ * referenced timer 是最后一道保险,避免某个依赖升级或异常 close/unref 让进程在
39
+ * 没有信号、异常或退出码的情况下静默消失。定时检查同时会重新 ref Server,并在
40
+ * Server 确实停止监听时串行触发恢复,避免只把一个失去服务能力的僵尸进程留下来。
41
+ */
42
+ export function createServiceLifecycleGuard(
43
+ options: ServiceLifecycleGuardOptions = {},
44
+ ): ServiceLifecycleGuard {
45
+ const intervalMs = options.intervalMs ?? DEFAULT_SERVICE_HEALTH_INTERVAL_MS;
46
+ const setIntervalImpl = options.setIntervalImpl
47
+ ?? ((callback, delayMs) => setInterval(callback, delayMs));
48
+ const clearIntervalImpl = options.clearIntervalImpl
49
+ ?? ((timer) => clearInterval(timer as NodeJS.Timeout));
50
+ const tracer = options.tracer ?? (() => {});
51
+ const getActiveResourcesInfo = options.getActiveResourcesInfo
52
+ ?? (() => process.getActiveResourcesInfo());
53
+
54
+ let timer: RefTimer | null = null;
55
+ let server: ServiceLifecycleServer | null = null;
56
+ let recoverServer: (() => void | Promise<void>) | undefined;
57
+ let recoveryPromise: Promise<void> | null = null;
58
+ let shuttingDown = false;
59
+
60
+ const trace = (message: string, extra?: Record<string, unknown>): void => {
61
+ try { tracer(message, extra); } catch { /* 诊断路径不能反过来打断服务 */ }
62
+ };
63
+
64
+ const serverAddress = (): unknown => {
65
+ try { return server?.address() ?? null; } catch { return null; }
66
+ };
67
+
68
+ const activeResources = (): string[] => {
69
+ try { return getActiveResourcesInfo(); } catch { return []; }
70
+ };
71
+
72
+ const diagnostics = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
73
+ ...extra,
74
+ uptimeSeconds: Math.floor(process.uptime()),
75
+ activeResources: activeResources(),
76
+ serverAttached: server !== null,
77
+ serverListening: server?.listening ?? false,
78
+ serverAddress: serverAddress(),
79
+ });
80
+
81
+ const start = (): void => {
82
+ if (shuttingDown || timer) return;
83
+ timer = setIntervalImpl(() => { void checkNow(); }, intervalMs);
84
+ // Node 的 Timeout 默认就是 ref 状态;显式 ref 让常驻服务契约不会依赖默认值。
85
+ try { timer.ref?.(); } catch { /* ignore */ }
86
+ trace("service-lifecycle: guard started", { intervalMs });
87
+ };
88
+
89
+ const checkNow = async (): Promise<void> => {
90
+ if (shuttingDown || !server) return;
91
+ if (server.listening) {
92
+ try { server.ref(); } catch (err) {
93
+ trace("service-lifecycle: HTTP server ref failed", diagnostics({
94
+ error: (err as Error).message,
95
+ }));
96
+ }
97
+ return;
98
+ }
99
+
100
+ if (recoveryPromise) return recoveryPromise;
101
+ trace("service-lifecycle: HTTP server inactive", diagnostics());
102
+ if (!recoverServer) return;
103
+
104
+ recoveryPromise = Promise.resolve()
105
+ .then(() => recoverServer?.())
106
+ .then(() => {
107
+ if (server?.listening) {
108
+ try { server.ref(); } catch { /* 下一轮健康检查会再次尝试 */ }
109
+ trace("service-lifecycle: HTTP server recovered", diagnostics());
110
+ } else {
111
+ trace("service-lifecycle: HTTP recovery completed without listening", diagnostics());
112
+ }
113
+ })
114
+ .catch((err: unknown) => {
115
+ trace("service-lifecycle: HTTP server recovery failed", diagnostics({
116
+ error: err instanceof Error ? err.message : String(err),
117
+ }));
118
+ })
119
+ .finally(() => {
120
+ recoveryPromise = null;
121
+ });
122
+ return recoveryPromise;
123
+ };
124
+
125
+ const attachServer = (
126
+ nextServer: ServiceLifecycleServer,
127
+ nextRecoverServer?: () => void | Promise<void>,
128
+ ): void => {
129
+ server = nextServer;
130
+ recoverServer = nextRecoverServer;
131
+ if (server.listening) {
132
+ try { server.ref(); } catch { /* 下一轮健康检查会记录 */ }
133
+ }
134
+ trace("service-lifecycle: HTTP server attached", diagnostics());
135
+ };
136
+
137
+ const handleBeforeExit = (code: number): void => {
138
+ if (shuttingDown) return;
139
+ trace("service-lifecycle: unexpected beforeExit", diagnostics({ code }));
140
+ start();
141
+ void checkNow();
142
+ };
143
+
144
+ const beginShutdown = (reason: string): void => {
145
+ if (shuttingDown) return;
146
+ shuttingDown = true;
147
+ if (timer) {
148
+ try { clearIntervalImpl(timer); } catch { /* process 即将退出 */ }
149
+ timer = null;
150
+ }
151
+ trace("service-lifecycle: shutdown requested", { reason });
152
+ };
153
+
154
+ return { start, attachServer, checkNow, handleBeforeExit, beginShutdown };
155
+ }
156
+
3
157
  /**
4
158
  * ChatCCC 自己拉起替代进程时使用的内部标记。
5
159
  *
package/src/web-ui.ts CHANGED
@@ -2131,7 +2131,10 @@ export function setExtraApiHandler(handler: ExtraApiHandler): void {
2131
2131
  extraApiHandler = handler;
2132
2132
  }
2133
2133
 
2134
- export function startSetupMode(port: number, options: StartSetupModeOptions = {}): void {
2134
+ export function startSetupMode(
2135
+ port: number,
2136
+ options: StartSetupModeOptions = {},
2137
+ ): ReturnType<typeof createServer> {
2135
2138
  const router = createUiRouter();
2136
2139
  const server = createServer(router);
2137
2140
  setupHttpServer = server;
@@ -2167,4 +2170,5 @@ export function startSetupMode(port: number, options: StartSetupModeOptions = {}
2167
2170
  console.log("");
2168
2171
  if (options.openBrowser !== false) openWebUiInDefaultBrowser(port);
2169
2172
  });
2173
+ return server;
2170
2174
  }