@maiyunnet/kebab 9.17.1 → 9.17.3

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/sys/child.js CHANGED
@@ -47,6 +47,10 @@ let http2Server;
47
47
  const linkCount = {};
48
48
  /** --- 是否正在停止,停止时对 HTTP/1.1 响应追加 Connection: close,避免请求完成后连接回到保活池 --- */
49
49
  let stopping = false;
50
+ /** --- 当前缓动停止任务,避免重复 IPC 消息触发停止流程 --- */
51
+ let stopPromise = null;
52
+ /** --- 缓动停止是否必须一直等到现有连接全部结束 --- */
53
+ let stopWaitForever = false;
50
54
  /** --- 当前活跃的 HTTP/2 会话集合,用于 graceful shutdown 时发送 GOAWAY 帧 --- */
51
55
  const http2Sessions = new Set();
52
56
  /**
@@ -552,56 +556,66 @@ async function reloadCert() {
552
556
  defaultSc = certList.size > 0 ? certList.values().next().value.sc : undefined;
553
557
  certHostIndex = {};
554
558
  }
559
+ /**
560
+ * --- 停止接收新连接,并在现有请求、上传、异步任务和 WebSocket 全部结束后退出 worker ---
561
+ * @param waitForever 是否取消原有的一小时退出兜底,仅完整 stop 命令使用
562
+ * @returns worker 退出时结束
563
+ */
564
+ async function gracefulStop(waitForever = false) {
565
+ if (waitForever) {
566
+ stopWaitForever = true;
567
+ }
568
+ if (stopPromise) {
569
+ return stopPromise;
570
+ }
571
+ stopping = true;
572
+ stopPromise = (async () => {
573
+ // --- 向所有 HTTP/2 会话发送 GOAWAY 帧,通知客户端停止在该连接上发送新请求并切换到新连接 ---
574
+ for (const session of http2Sessions) {
575
+ try {
576
+ session.goaway(http2.constants.NGHTTP2_NO_ERROR);
577
+ }
578
+ catch { }
579
+ }
580
+ httpServer?.close();
581
+ http2Server?.close();
582
+ // --- 立即关闭空闲保活连接,活跃连接会继续执行并在响应后关闭 ---
583
+ httpServer?.closeIdleConnections();
584
+ clearInterval(hbTimer);
585
+ sMonitor.stop();
586
+ // --- 等待活跃请求全部完成;WebSocket 的路由任务会一直保留到连接关闭 ---
587
+ /** --- 当前已等待时间,restart 最多等待一小时,完整 stop 不限制 --- */
588
+ let waiting = 0;
589
+ while (Object.keys(linkCount).length) {
590
+ const str = [];
591
+ for (const key in linkCount) {
592
+ str.push(key + ':' + linkCount[key].toString());
593
+ }
594
+ lCore.debug(`[CHILD] Worker ${process.pid} busy: ${str.join(',')}.`);
595
+ lCore.log({}, `[CHILD] Worker ${process.pid} busy: ${str.join(',')}.`, '-warning');
596
+ await lCore.sleep(5_000);
597
+ waiting += 5_000;
598
+ // --- 再次清理已变为空闲的保活连接 ---
599
+ httpServer?.closeIdleConnections();
600
+ if (!stopWaitForever && (waiting > 3_600_000)) {
601
+ break;
602
+ }
603
+ }
604
+ process.exit();
605
+ })();
606
+ return stopPromise;
607
+ }
555
608
  // --- 接收主进程回传信号,主要用来 reload,restart ---
556
609
  process.on('message', function (msg) {
557
610
  (async function () {
558
611
  switch (msg.action) {
559
612
  case 'reload': {
560
613
  await reload();
561
- // eslint-disable-next-line no-console
562
- console.log(`[child] Worker ${process.pid} reload execution succeeded.`);
614
+ lCore.display(`[CHILD] Worker ${process.pid} reload execution succeeded.`);
563
615
  break;
564
616
  }
565
617
  case 'stop': {
566
- // --- 需要停止监听,等待已有连接全部断开,然后关闭线程 ---
567
- stopping = true;
568
- // --- 向所有 HTTP/2 会话发送 GOAWAY 帧,通知 CDN 客户端停止在该连接上发送新请求并切换到新连接 ---
569
- for (const session of http2Sessions) {
570
- try {
571
- session.goaway(http2.constants.NGHTTP2_NO_ERROR);
572
- }
573
- catch { }
574
- }
575
- httpServer?.close();
576
- http2Server?.close();
577
- // --- 立即关闭空闲保活连接(无活跃请求的 keep-alive socket),避免进程长时间等待 ---
578
- httpServer?.closeIdleConnections();
579
- clearInterval(hbTimer);
580
- sMonitor.stop();
581
- // --- 等待活跃请求全部完成 ---
582
- /** --- 当前已等待时间,等待不超过 1 小时 --- */
583
- let waiting = 0;
584
- while (true) {
585
- if (!Object.keys(linkCount).length) {
586
- break;
587
- }
588
- // --- 有活跃连接,等待中 ---
589
- const str = [];
590
- for (const key in linkCount) {
591
- str.push(key + ':' + linkCount[key].toString());
592
- }
593
- lCore.debug(`[CHILD] Worker ${process.pid} busy: ${str.join(',')}.`);
594
- lCore.log({}, `[CHILD] Worker ${process.pid} busy: ${str.join(',')}.`, '-warning');
595
- await lCore.sleep(5_000);
596
- waiting += 5_000;
597
- // --- 再次清理已变为空闲的保活连接 ---
598
- httpServer?.closeIdleConnections();
599
- if (waiting > 3600_000) {
600
- break;
601
- }
602
- }
603
- // --- 链接全部断开 ---
604
- process.exit();
618
+ await gracefulStop(msg.waitForever === true);
605
619
  break;
606
620
  }
607
621
  case 'global': {
package/sys/master.js CHANGED
@@ -19,6 +19,10 @@ import * as lTime from '#kebab/lib/time.js';
19
19
  import * as lZip from '#kebab/lib/zip.js';
20
20
  /** --- 当前运行中的子进程列表 --- */
21
21
  const workerList = {};
22
+ /** --- master 是否正在缓动停止 --- */
23
+ let stopping = false;
24
+ /** --- 当前缓动停止任务,避免重复 stop 命令触发 --- */
25
+ let stopPromise = null;
22
26
  /**
23
27
  * --- 判断候选路径是否位于指定根目录内 ---
24
28
  * @param rootPath 已解析的真实根目录
@@ -246,6 +250,12 @@ function createRpcListener() {
246
250
  await restartWorkers();
247
251
  break;
248
252
  }
253
+ case 'stop': {
254
+ gracefulStop().catch((e) => {
255
+ lCore.display('[master] [gracefulStop]', e);
256
+ });
257
+ break;
258
+ }
249
259
  case 'global': {
250
260
  // --- 为所有子进程更新 global 变量 ---
251
261
  for (const pid in workerList) {
@@ -264,7 +274,7 @@ function createRpcListener() {
264
274
  break;
265
275
  }
266
276
  case 'pm2': {
267
- // --- 执行 PM2 操作 ---
277
+ // --- 接收 PM2 操作 ---
268
278
  if (!msg.name || typeof msg.name !== 'string') {
269
279
  res.end('Invalid name');
270
280
  return;
@@ -279,12 +289,18 @@ function createRpcListener() {
279
289
  res.end('Invalid pm2Action');
280
290
  return;
281
291
  }
282
- const rtn = await lCore.exec(`pm2 ${msg.pm2Action} ${msg.name}`);
283
- if (rtn === false) {
284
- res.end('Exec failed');
285
- return;
286
- }
287
- break;
292
+ // --- 先确认已接收,让调用方完成当前 HTTP 响应,再执行可能终止本进程的 PM2 操作 ---
293
+ res.end('Done');
294
+ setTimeout(() => {
295
+ lCore.exec(`pm2 ${msg.pm2Action} ${msg.name}`).then((rtn) => {
296
+ if (rtn === false) {
297
+ lCore.display(`[master] PM2 ${msg.pm2Action} ${msg.name} failed.`);
298
+ }
299
+ }).catch((e) => {
300
+ lCore.display(`[master] PM2 ${msg.pm2Action} ${msg.name} failed.`, e);
301
+ });
302
+ }, 1_000);
303
+ return;
288
304
  }
289
305
  case 'npm': {
290
306
  // --- 执行 npm install 操作 ---
@@ -931,10 +947,59 @@ function createRpcListener() {
931
947
  });
932
948
  }).listen(lCore.globalConfig.rpcPort);
933
949
  }
950
+ /**
951
+ * --- 通知 worker 停止,并等待其现有连接全部结束后退出 ---
952
+ * @param worker 要停止的 worker
953
+ * @returns worker 退出后完成
954
+ */
955
+ function stopWorker(worker) {
956
+ if (worker.isDead()) {
957
+ return Promise.resolve();
958
+ }
959
+ return new Promise((resolve) => {
960
+ worker.once('exit', () => { resolve(); });
961
+ try {
962
+ if (worker.isConnected()) {
963
+ worker.send({
964
+ 'action': 'stop',
965
+ 'waitForever': true,
966
+ });
967
+ return;
968
+ }
969
+ worker.process.kill('SIGTERM');
970
+ }
971
+ catch (e) {
972
+ lCore.display(`[master] Failed to notify worker ${worker.process.pid ?? 'undefined'} to stop, using SIGTERM.`, e);
973
+ worker.process.kill('SIGTERM');
974
+ }
975
+ });
976
+ }
977
+ /**
978
+ * --- 停止命令入口和所有 worker,等待整套 Kebab 进程排空后退出 master ---
979
+ * @returns 全部 worker 退出后完成
980
+ */
981
+ async function gracefulStop() {
982
+ if (stopPromise) {
983
+ return stopPromise;
984
+ }
985
+ stopping = true;
986
+ stopPromise = (async () => {
987
+ lCore.display('[master] Graceful stop started.');
988
+ // --- 包括 restart 时已从 workerList 移除、但仍在排空的旧 worker ---
989
+ const workers = Object.values(cluster.workers ?? {}).filter((worker) => worker !== undefined);
990
+ await Promise.all(workers.map(worker => stopWorker(worker)));
991
+ lCore.display('[master] Graceful stop completed.');
992
+ process.exit(0);
993
+ })();
994
+ return stopPromise;
995
+ }
934
996
  /**
935
997
  * --- 零宕机重启所有 worker 进程:先创建新进程并等待监听就绪,再停止旧进程 ---
936
998
  */
937
999
  async function restartWorkers() {
1000
+ if (stopping) {
1001
+ return;
1002
+ }
938
1003
  // --- 收集旧进程信息(先快照,避免迭代过程中修改 workerList) ---
939
1004
  const oldEntries = [];
940
1005
  for (const pid in workerList) {
@@ -946,6 +1011,9 @@ async function restartWorkers() {
946
1011
  }
947
1012
  // --- 阶段 1:创建新进程并等待其监听端口就绪 ---
948
1013
  for (const entry of oldEntries) {
1014
+ if (stopping) {
1015
+ return;
1016
+ }
949
1017
  const newWorker = await createChildProcess(entry.cpu);
950
1018
  if (newWorker) {
951
1019
  await waitForWorkerListening(newWorker);
@@ -953,6 +1021,9 @@ async function restartWorkers() {
953
1021
  }
954
1022
  // --- 阶段 2:停止旧进程 ---
955
1023
  for (const entry of oldEntries) {
1024
+ if (stopping) {
1025
+ return;
1026
+ }
956
1027
  entry.worker.send({
957
1028
  'action': 'stop'
958
1029
  });
@@ -963,6 +1034,9 @@ async function restartWorkers() {
963
1034
  * --- 检测是否有死掉丢失的子进程,复活之 ---
964
1035
  */
965
1036
  async function checkWorkerLost() {
1037
+ if (stopping) {
1038
+ return;
1039
+ }
966
1040
  const now = Date.now();
967
1041
  for (const pid in workerList) {
968
1042
  if (now - workerList[pid].hbtime < 30000) {
@@ -984,6 +1058,9 @@ async function checkWorkerLost() {
984
1058
  * @param cpu CPU ID
985
1059
  */
986
1060
  async function createChildProcess(cpu) {
1061
+ if (stopping) {
1062
+ return null;
1063
+ }
987
1064
  const worker = cluster.fork();
988
1065
  if (!worker.process.pid) {
989
1066
  return null;
@@ -1017,6 +1094,12 @@ async function createChildProcess(cpu) {
1017
1094
  await restartWorkers();
1018
1095
  break;
1019
1096
  }
1097
+ case 'stop': {
1098
+ gracefulStop().catch((e) => {
1099
+ lCore.display('[master] [gracefulStop]', e);
1100
+ });
1101
+ break;
1102
+ }
1020
1103
  case 'global': {
1021
1104
  // --- 为所有子进程更新 global 变量 ---
1022
1105
  for (const pid in workerList) {
@@ -56,6 +56,7 @@ export default class extends sCtr.Ctr {
56
56
  coreUpdatecode(): Promise<string>;
57
57
  coreReload(): Promise<string>;
58
58
  coreRestart(): Promise<string>;
59
+ coreStop(): Promise<string>;
59
60
  corePm2(): Promise<string>;
60
61
  coreNpm(): Promise<any>;
61
62
  coreGlobal(): Promise<string>;
@@ -169,6 +169,7 @@ export default class extends sCtr.Ctr {
169
169
  `<br><a href="${this._config.const.urlBase}test/core-ls">View "test/core-ls"</a>`,
170
170
  `<br><a href="${this._config.const.urlBase}test/core-reload">View "test/core-reload"</a>`,
171
171
  `<br><a href="${this._config.const.urlBase}test/core-restart">View "test/core-restart"</a>`,
172
+ `<br><a href="${this._config.const.urlBase}test/core-stop">View "test/core-stop"</a>`,
172
173
  `<br><a href="${this._config.const.urlBase}test/core-pm2?name=cron&action=restart">View "test/core-pm2"</a>`,
173
174
  `<br><a href="${this._config.const.urlBase}test/core-npm">View "test/core-npm"</a>`,
174
175
  `<br><a href="${this._config.const.urlBase}test/core-global">View "test/core-global"</a>`,
@@ -1572,6 +1573,10 @@ to: ${to}`
1572
1573
  const list = await lCore.sendRestart();
1573
1574
  return `The restart request has been sent, please review the console.<br>Hosts: ${JSON.stringify(list)}<br><br>` + this._getEnd();
1574
1575
  }
1576
+ async coreStop() {
1577
+ const list = await lCore.sendStop();
1578
+ return `The stop request has been sent, please review the console.<br>Hosts: ${lText.stringifyJson(list)}<br><br>` + this._getEnd();
1579
+ }
1575
1580
  async corePm2() {
1576
1581
  const name = this._get['name'] ?? '';
1577
1582
  const action = (this._get['action'] ?? 'restart');
@@ -2499,10 +2504,29 @@ error: <pre>${JSON.stringify(res.error, null, 4)}</pre>`);
2499
2504
  const echo = [];
2500
2505
  const json = await lUndici.getResponseJson(this._internalUrl + 'test/undici-get-response-json1');
2501
2506
  echo.push(`<pre>const json = await lUndici.getResponseJson('${this._internalUrl}test/undici-get-response-json1');</pre>
2502
- result: <pre>${lText.htmlescape(JSON.stringify(json, null, 4))}</pre>`);
2507
+ result: <pre>${lText.htmlescape(JSON.stringify(json, null, 4))}</pre><hr>`);
2503
2508
  const json2 = await lUndici.getResponseJson(this._internalUrl + 'test/undici-get-response-json2');
2504
2509
  echo.push(`<pre>const json2 = await lUndici.getResponseJson('${this._internalUrl}test/undici-get-response-json2');</pre>
2505
- result: <pre>${lText.htmlescape(JSON.stringify(json2, null, 4))}</pre>`);
2510
+ result: <pre>${lText.htmlescape(JSON.stringify(json2, null, 4))}</pre><hr>`);
2511
+ /** --- JSON 重试验证方法的执行次数 --- */
2512
+ let retryJsonHandlerCount = 0;
2513
+ const json3 = await lUndici.getResponseJson(this._internalUrl + 'test/undici-get-response-json1', {
2514
+ 'retryJson': 1,
2515
+ 'retryJsonHandler': (value) => {
2516
+ ++retryJsonHandlerCount;
2517
+ return (retryJsonHandlerCount > 1) && (value.result === 1);
2518
+ },
2519
+ });
2520
+ echo.push(`<pre>let retryJsonHandlerCount = 0;
2521
+ const json3 = await lUndici.getResponseJson('${this._internalUrl}test/undici-get-response-json1', {
2522
+ 'retryJson': 1,
2523
+ 'retryJsonHandler': (value) => {
2524
+ ++retryJsonHandlerCount;
2525
+ return (retryJsonHandlerCount > 1) && (value.result === 1);
2526
+ },
2527
+ });</pre>
2528
+ handler count: ${retryJsonHandlerCount}
2529
+ result: <pre>${lText.htmlescape(JSON.stringify(json3, null, 4))}</pre>`);
2506
2530
  return echo.join('') + '<br>' + this._getEnd();
2507
2531
  }
2508
2532
  undiciGetResponseJson1() {