@maiyunnet/kebab 9.17.0 → 9.17.2

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/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * --- 本文件用来定义每个目录实体地址的常量 ---
6
6
  */
7
7
  /** --- 当前系统版本号 --- */
8
- export declare const VER = "9.17.0";
8
+ export declare const VER = "9.17.2";
9
9
  /** --- 框架根目录,以 / 结尾 --- */
10
10
  export declare const ROOT_PATH: string;
11
11
  /** --- 框架的 LIB,以 / 结尾 --- */
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * --- 本文件用来定义每个目录实体地址的常量 ---
7
7
  */
8
8
  /** --- 当前系统版本号 --- */
9
- export const VER = '9.17.0';
9
+ export const VER = '9.17.2';
10
10
  // --- 服务端用的路径 ---
11
11
  const imu = decodeURIComponent(import.meta.url).replace('file://', '').replace(/^\/(\w:)/, '$1');
12
12
  /** --- /xxx/xxx --- */
package/lib/core.d.ts CHANGED
@@ -206,6 +206,15 @@ export declare function sendRestart(hosts?: string[] | 'config'): Promise<Record
206
206
  'result': boolean;
207
207
  'return': string;
208
208
  }>>;
209
+ /**
210
+ * --- 向主进程(或局域网同代码机子)发送 stop 操作,停止接收新连接并在现有连接全部结束后退出 ---
211
+ * @param hosts 局域网主机列表,config 表示使用全局配置;不传时通知当前 master
212
+ * @returns 各主机发送结果
213
+ */
214
+ export declare function sendStop(hosts?: string[] | 'config'): Promise<Record<string, {
215
+ 'result': boolean;
216
+ 'return': string;
217
+ }>>;
209
218
  /** --- PM2 操作类型 --- */
210
219
  export type TPm2Action = 'start' | 'stop' | 'restart';
211
220
  /**
@@ -213,6 +222,7 @@ export type TPm2Action = 'start' | 'stop' | 'restart';
213
222
  * @param name PM2 进程名称
214
223
  * @param action PM2 操作类型
215
224
  * @param hosts 局域网列表
225
+ * @returns 各主机是否已接收并排队,不代表 PM2 操作最终执行成功
216
226
  */
217
227
  export declare function sendPm2(name: string, action?: TPm2Action, hosts?: string[] | 'config'): Promise<Record<string, {
218
228
  'result': boolean;
package/lib/core.js CHANGED
@@ -713,11 +713,62 @@ export async function sendRestart(hosts) {
713
713
  }
714
714
  return rtn;
715
715
  }
716
+ /**
717
+ * --- 向主进程(或局域网同代码机子)发送 stop 操作,停止接收新连接并在现有连接全部结束后退出 ---
718
+ * @param hosts 局域网主机列表,config 表示使用全局配置;不传时通知当前 master
719
+ * @returns 各主机发送结果
720
+ */
721
+ export async function sendStop(hosts) {
722
+ if (!hosts) {
723
+ // --- 本地模式 ---
724
+ // eslint-disable-next-line no-console
725
+ console.log('[ Child] Sending stop request...');
726
+ process.send({
727
+ 'action': 'stop'
728
+ });
729
+ return {
730
+ '127.0.0.1': { 'result': true, 'return': 'Done' }
731
+ };
732
+ }
733
+ if (hosts === 'config') {
734
+ hosts = globalConfig.hosts;
735
+ }
736
+ // --- 未传或 config 展开后为空数组,均回退到本机 ---
737
+ if (!hosts?.length) {
738
+ hosts = ['127.0.0.1'];
739
+ }
740
+ // --- 局域网模式 ---
741
+ const time = lTime.stamp();
742
+ /** --- 返回成功的 host --- */
743
+ const rtn = {};
744
+ for (const host of hosts) {
745
+ const res = await lUndici.get('http://' + host + ':' + globalConfig.rpcPort.toString() + '/' + lCrypto.aesEncrypt(lText.stringifyJson({
746
+ 'action': 'stop',
747
+ 'time': time
748
+ }), globalConfig.rpcSecret), {
749
+ 'timeout': 5
750
+ });
751
+ const content = await res.getContent();
752
+ if (!content) {
753
+ rtn[host] = { 'result': false, 'return': 'Timeout' };
754
+ continue;
755
+ }
756
+ const str = content.toString();
757
+ if (str === 'Done') {
758
+ rtn[host] = { 'result': true, 'return': 'Done' };
759
+ }
760
+ else {
761
+ rtn[host] = { 'result': false, 'return': str };
762
+ }
763
+ }
764
+ return rtn;
765
+ }
716
766
  /**
717
767
  * --- 向本机或局域网 RPC 发送 PM2 操作 ---
718
768
  * @param name PM2 进程名称
719
769
  * @param action PM2 操作类型
720
770
  * @param hosts 局域网列表
771
+ * @returns 各主机是否已接收并排队,不代表 PM2 操作最终执行成功
721
772
  */
722
773
  export async function sendPm2(name, action = 'restart', hosts) {
723
774
  if (hosts === 'config') {
package/lib/db/conn.d.ts CHANGED
@@ -53,9 +53,12 @@ export declare class Connection {
53
53
  */
54
54
  isUsing(): boolean;
55
55
  /**
56
- * --- 判断是否可用(丢失的也算不可用),返回 true 代表获取成功并自动刷新最后时间 ---
56
+ * --- 判断是否可用(丢失的也算不可用),返回 true 代表获取成功 ---
57
+ * @param opt 获取选项,连接巡检独占时不刷新最后使用时间
57
58
  */
58
- using(): boolean;
59
+ using(opt?: {
60
+ 'refreshLast'?: boolean;
61
+ }): boolean;
59
62
  /**
60
63
  * --- 取消占用 ---
61
64
  */
@@ -85,7 +88,12 @@ export declare class Connection {
85
88
  * --- 关闭连接,一般情况下不使用 ---
86
89
  */
87
90
  end(): Promise<boolean>;
88
- beginTransaction(): Promise<boolean>;
91
+ /**
92
+ * --- 开启事务,只能在独占连接中使用 ---
93
+ * @param logError 失败时是否记录错误,连接池在非最后一次重试时传 false
94
+ * @returns 是否开启成功
95
+ */
96
+ beginTransaction(logError?: boolean): Promise<boolean>;
89
97
  commit(): Promise<boolean>;
90
98
  rollback(): Promise<boolean>;
91
99
  }
package/lib/db/conn.js CHANGED
@@ -99,14 +99,17 @@ export class Connection {
99
99
  return this._using;
100
100
  }
101
101
  /**
102
- * --- 判断是否可用(丢失的也算不可用),返回 true 代表获取成功并自动刷新最后时间 ---
102
+ * --- 判断是否可用(丢失的也算不可用),返回 true 代表获取成功 ---
103
+ * @param opt 获取选项,连接巡检独占时不刷新最后使用时间
103
104
  */
104
- using() {
105
+ using(opt = {}) {
105
106
  if (this._lost || this._using) {
106
107
  return false;
107
108
  }
108
109
  else {
109
- this.refreshLast();
110
+ if (opt.refreshLast !== false) {
111
+ this.refreshLast();
112
+ }
110
113
  this._using = true;
111
114
  return true;
112
115
  }
@@ -281,6 +284,8 @@ export class Connection {
281
284
  * --- 关闭连接,一般情况下不使用 ---
282
285
  */
283
286
  async end() {
287
+ // --- 驱动的 end 事件可能在异步关闭完成后才触发,提前标记避免连接池在关闭期间重新取到本连接 ---
288
+ this._lost = true;
284
289
  try {
285
290
  await this._link.end();
286
291
  return true;
@@ -289,8 +294,12 @@ export class Connection {
289
294
  return false;
290
295
  }
291
296
  }
292
- // --- 事务,只能在独占连接中使用,pool 创建事务返回独占连接,commit 或 rollback 释放连接回连接池 ---
293
- async beginTransaction() {
297
+ /**
298
+ * --- 开启事务,只能在独占连接中使用 ---
299
+ * @param logError 失败时是否记录错误,连接池在非最后一次重试时传 false
300
+ * @returns 是否开启成功
301
+ */
302
+ async beginTransaction(logError = true) {
294
303
  if (this._using) {
295
304
  try {
296
305
  this._transaction = true;
@@ -306,12 +315,16 @@ export class Connection {
306
315
  this._transaction = false;
307
316
  this._using = false;
308
317
  this._lost = true;
309
- lCore.log({}, '[DB][Connection][beginTransaction] ' + lText.stringifyError(e), '-error');
318
+ if (logError) {
319
+ lCore.log({}, '[DB][Connection][beginTransaction] ' + lText.stringifyError(e), '-error');
320
+ }
310
321
  return false;
311
322
  }
312
323
  }
313
324
  else {
314
- lCore.log({}, '[DB][Connection][beginTransaction] connection is not in use', '-error');
325
+ if (logError) {
326
+ lCore.log({}, '[DB][Connection][beginTransaction] connection is not in use', '-error');
327
+ }
315
328
  return false;
316
329
  }
317
330
  }
package/lib/db/pool.js CHANGED
@@ -67,6 +67,10 @@ async function checkConnection() {
67
67
  }
68
68
  continue;
69
69
  }
70
+ // --- 巡检探活或关闭前先独占连接,避免业务在异步操作期间取到同一连接 ---
71
+ if (!connection.using({ 'refreshLast': false })) {
72
+ continue;
73
+ }
70
74
  if (connection.getLast() <= now - 30) {
71
75
  // --- 超 30 秒未被使用,则关闭 ---
72
76
  await connection.end();
@@ -75,8 +79,9 @@ async function checkConnection() {
75
79
  continue;
76
80
  }
77
81
  // --- 30 秒内使用过,看看连接是否正常 ---
78
- if (await connection.isAvailable(false)) {
82
+ if (await connection.isAvailable(false) && !connection.isLost()) {
79
83
  // --- 正常 ---
84
+ connection.used();
80
85
  continue;
81
86
  }
82
87
  // --- 连接有问题,直接关闭 ---
@@ -120,7 +125,7 @@ export class Pool {
120
125
  */
121
126
  async query(sql, values) {
122
127
  ++this._queries;
123
- // --- 获取并自动 using ---
128
+ // --- 获取并自动 using ---
124
129
  const conn = await this._getConnection();
125
130
  if (!conn) {
126
131
  return {
@@ -167,7 +172,9 @@ export class Pool {
167
172
  lCore.log(ctr ?? {}, `[DB][Pool][beginTransaction] failed to get connection, service: ${lDb.ESERVICE[this._service]}, database: ${this._etc.name ?? ''}`, '-error');
168
173
  return null;
169
174
  }
170
- if (!await conn.beginTransaction()) {
175
+ /** --- 只有最后一次尝试仍失败时才记录原始错误,避免已恢复的重试被误判为业务失败 --- */
176
+ const logError = i === BEGIN_TRANSACTION_MAX_ATTEMPTS - 1;
177
+ if (!await conn.beginTransaction(logError)) {
171
178
  continue;
172
179
  }
173
180
  return new Transaction(ctr, conn);
@@ -31,7 +31,7 @@ export declare class Response {
31
31
  /**
32
32
  * --- 获取响应读取流对象 ---
33
33
  */
34
- getStream(): (import("undici/types/readable").default & undici.Dispatcher.BodyMixin) | zlib.Gunzip | zlib.Inflate | zlib.BrotliDecompress | null;
34
+ getStream(): zlib.BrotliDecompress | zlib.Gunzip | zlib.Inflate | (import("undici/types/readable").default & undici.Dispatcher.BodyMixin) | null;
35
35
  /**
36
36
  * --- 获取原生响应读取流对象 ---
37
37
  */
package/lib/undici.d.ts CHANGED
@@ -133,6 +133,8 @@ export interface IRequestOptions {
133
133
  'retry'?: number;
134
134
  /** --- JSON 解析失败后的重试次数,默认 0;仅适用于 ResponseJson 快捷方法,非幂等请求需由调用方保证安全 --- */
135
135
  'retryJson'?: number;
136
+ /** --- JSON 重试验证方法;返回 false 时按 retryJson 的设置重试,仅适用于 ResponseJson 快捷方法 --- */
137
+ 'retryJsonHandler'?: (json: kebab.Json) => boolean;
136
138
  /** --- 追踪 location 次数,0 为不追踪,默认为 0 --- */
137
139
  'follow'?: number;
138
140
  /** --- 自定义 host 映射,如 {'www.maiyun.net': '127.0.0.1'},或全部映射到一个 host --- */
package/lib/undici.js CHANGED
@@ -152,7 +152,7 @@ async function requestResponseJson(u, data, opt, action) {
152
152
  }
153
153
  const rtnStr = rtn.toString();
154
154
  const json = lText.parseJson(rtnStr);
155
- if (json) {
155
+ if (json && (opt.retryJsonHandler?.(json) ?? true)) {
156
156
  return json;
157
157
  }
158
158
  if (i < retryJson) {
@@ -162,7 +162,8 @@ async function requestResponseJson(u, data, opt, action) {
162
162
  }
163
163
  if (opt.log === undefined || opt.log) {
164
164
  const requestData = data === undefined ? '' : `, data: ${lText.stringifyJson(data)}`;
165
- lCore.log({}, `[UNDICI][${action}] parse json failed, url: ${u}${requestData}, content: ${rtnStr}`, '-neterror');
165
+ const reason = json ? 'retry json handler returned false' : 'parse json failed';
166
+ lCore.log({}, `[UNDICI][${action}] ${reason}, url: ${u}${requestData}, content: ${rtnStr}`, '-neterror');
166
167
  }
167
168
  return false;
168
169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maiyunnet/kebab",
3
- "version": "9.17.0",
3
+ "version": "9.17.2",
4
4
  "description": "Simple, easy-to-use, and fully-featured Node.js framework that is ready-to-use out of the box.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -35,7 +35,7 @@
35
35
  "@tailwindcss/cli": "^4.3.3",
36
36
  "@types/ssh2": "^1.15.5",
37
37
  "ajv": "^8.20.0",
38
- "ajv-formats": "^3.0.1",
38
+ "ajv-formats": "^3.0.1",
39
39
  "class-variance-authority": "^0.7.1",
40
40
  "clsx": "^2.1.1",
41
41
  "ejs": "^6.0.1",
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() {