Skip to content

Commit 7d9abb2

Browse files
author
huzijie.sea
committed
feat(graceful-shutdown): 实现优雅退出机制
添加全局优雅退出管理器,处理未捕获异常和信号事件 在退出时自动清理后台进程、MCP连接和Hook资源
1 parent 94764d0 commit 7d9abb2

7 files changed

Lines changed: 374 additions & 6 deletions

File tree

CODE_OF_CONDUCT.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Code of Conduct
2+
3+
## Our Pledge
4+
5+
As members, contributors, and maintainers, we pledge to make participation in this
6+
project and our community a harassment-free experience for everyone, regardless of
7+
age, body size, visible or invisible disability, ethnicity, sex characteristics,
8+
gender identity and expression, level of experience, education, socio-economic
9+
status, nationality, personal appearance, race, caste, color, religion, or
10+
sexual identity and orientation.
11+
12+
We pledge to act and interact in ways that contribute to an open, welcoming,
13+
diverse, inclusive, and healthy community.
14+
15+
## Our Standards
16+
17+
### Examples of positive behavior include:
18+
19+
- Using welcoming and inclusive language
20+
- Respecting different viewpoints and experiences
21+
- Accepting constructive criticism gracefully
22+
- Focusing on what is best for the community
23+
- Showing empathy towards other community members
24+
- Making constructive contributions to AI- and development-related discussions
25+
26+
### Examples of unacceptable behavior include:
27+
28+
- **Hate speech**: derogatory or threatening comments toward a group, especially
29+
based on race, religion, gender, sexual orientation, or other protected traits
30+
- **Discriminatory language**: slurs, offensive comments, or demeaning jokes
31+
- **Personal attacks**: insults or hostile comments directed at individuals
32+
- **Harassment**: intimidation, stalking, following, or threatening others
33+
- **Doxxing**: publishing others’ private information without permission
34+
- **Spam**: excessive off-topic content, promotion, or repetitive posts
35+
- **Trolling**: deliberate provocation or disruptive behavior
36+
- **Sexual harassment**: unwelcome sexual attention or advances
37+
38+
## Enforcement
39+
40+
### Reporting
41+
42+
If you experience or witness unacceptable behavior, you can report it by:
43+
44+
- Creating an issue with the `moderation` label
45+
- Contacting the repository maintainers directly
46+
- Using GitHub’s built-in reporting tools
47+
48+
### Consequences
49+
50+
Project maintainers will follow these general guidelines when determining
51+
consequences:
52+
53+
1. **Warning** – for a first or minor offense
54+
2. **Temporary restriction** – temporary loss of interaction privileges
55+
3. **Permanent ban** – for severe or repeated violations
56+
57+
### Enforcement Actions
58+
59+
- **Immediate removal** – hate speech, threats, or doxxing may result in
60+
immediate content removal and bans
61+
- **Closing Issues/PRs** – inappropriate content may be closed and locked
62+
- **Account bans** – repeat offenders may be banned from the repository
63+
64+
## Scope
65+
66+
This Code of Conduct applies to all project spaces, including but not limited to:
67+
68+
- Issues and Pull Requests
69+
- Discussions and comments
70+
- Wiki and documentation
71+
- Public representation of the project
72+
73+
## Attribution
74+
75+
This Code of Conduct is adapted from the
76+
[Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
77+
78+
## Contact
79+
80+
For questions or concerns about this Code of Conduct, please contact the
81+
repository maintainers via GitHub Issues or Discussions.

src/blade.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { handlePrintMode } from './commands/print.js';
2121
import { updateCommands } from './commands/update.js';
2222
import { getConfigService } from './config/index.js';
2323
import { Logger } from './logging/Logger.js';
24+
import { initializeGracefulShutdown } from './services/GracefulShutdown.js';
2425
import { AppWrapper as BladeApp } from './ui/App.js';
2526

2627
// ⚠️ 关键:在创建任何 logger 之前,先解析 --debug 参数并设置全局配置
@@ -35,6 +36,9 @@ if (debugIndex !== -1) {
3536
}
3637

3738
export async function main() {
39+
// 初始化优雅退出处理器(捕获 uncaughtException/unhandledRejection/SIGTERM)
40+
initializeGracefulShutdown();
41+
3842
// 首先检查是否是 print 模式
3943
if (await handlePrintMode()) {
4044
return;

src/mcp/McpRegistry.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EventEmitter } from 'events';
2-
import type { Tool } from '../tools/types/index.js';
32
import type { McpServerConfig } from '../config/types.js';
3+
import type { Tool } from '../tools/types/index.js';
44
import { createMcpTool } from './createMcpTool.js';
55
import { McpClient } from './McpClient.js';
66
import { McpConnectionStatus, type McpToolDefinition } from './types.js';
@@ -139,7 +139,7 @@ export class McpRegistry extends EventEmitter {
139139
const nameConflicts = new Map<string, number>();
140140

141141
// 第一遍:检测冲突
142-
for (const [serverName, serverInfo] of this.servers) {
142+
for (const [_serverName, serverInfo] of this.servers) {
143143
if (serverInfo.status === McpConnectionStatus.CONNECTED) {
144144
for (const mcpTool of serverInfo.tools) {
145145
const count = nameConflicts.get(mcpTool.name) || 0;
@@ -348,4 +348,25 @@ export class McpRegistry extends EventEmitter {
348348
isDiscovering: this.isDiscovering,
349349
};
350350
}
351+
352+
/**
353+
* 断开所有 MCP 服务器连接
354+
* 在应用退出时调用
355+
*/
356+
async disconnectAll(): Promise<void> {
357+
const disconnectPromises: Promise<void>[] = [];
358+
359+
for (const [name, serverInfo] of this.servers) {
360+
if (serverInfo.status === McpConnectionStatus.CONNECTED) {
361+
disconnectPromises.push(
362+
serverInfo.client.disconnect().catch((error) => {
363+
console.warn(`断开 MCP 服务器 "${name}" 时出错:`, error);
364+
})
365+
);
366+
}
367+
}
368+
369+
await Promise.allSettled(disconnectPromises);
370+
this.servers.clear();
371+
}
351372
}

src/services/GracefulShutdown.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/**
2+
* 优雅退出管理器
3+
*
4+
* 负责:
5+
* 1. 全局崩溃捕获 (uncaughtException/unhandledRejection)
6+
* 2. 信号处理 (SIGINT/SIGTERM)
7+
* 3. 资源清理和会话保存
8+
*/
9+
10+
import { createLogger, LogCategory } from '../logging/Logger.js';
11+
12+
const logger = createLogger(LogCategory.SERVICE);
13+
14+
/** 清理函数类型 */
15+
type CleanupHandler = () => void | Promise<void>;
16+
17+
/** 退出原因 */
18+
type ExitReason =
19+
| 'uncaughtException'
20+
| 'unhandledRejection'
21+
| 'SIGINT'
22+
| 'SIGTERM'
23+
| 'normal';
24+
25+
/**
26+
* 优雅退出管理器
27+
* 单例模式,确保全局只有一个实例处理退出逻辑
28+
*/
29+
class GracefulShutdownManager {
30+
private static instance: GracefulShutdownManager | null = null;
31+
32+
private cleanupHandlers: CleanupHandler[] = [];
33+
private isShuttingDown = false;
34+
private initialized = false;
35+
36+
private constructor() {}
37+
38+
static getInstance(): GracefulShutdownManager {
39+
if (!GracefulShutdownManager.instance) {
40+
GracefulShutdownManager.instance = new GracefulShutdownManager();
41+
}
42+
return GracefulShutdownManager.instance;
43+
}
44+
45+
/**
46+
* 初始化全局错误处理器
47+
* 应该在应用启动时调用一次
48+
*/
49+
initialize(): void {
50+
if (this.initialized) {
51+
logger.debug('[GracefulShutdown] 已初始化,跳过重复初始化');
52+
return;
53+
}
54+
55+
// 捕获未处理的异常
56+
process.on('uncaughtException', (error: Error) => {
57+
this.handleFatalError('uncaughtException', error);
58+
});
59+
60+
// 捕获未处理的 Promise 拒绝
61+
process.on('unhandledRejection', (reason: unknown) => {
62+
const error = reason instanceof Error ? reason : new Error(String(reason));
63+
this.handleFatalError('unhandledRejection', error);
64+
});
65+
66+
// 处理 SIGTERM(通常由进程管理器发送,如 Docker、PM2)
67+
process.on('SIGTERM', () => {
68+
logger.info('[GracefulShutdown] 收到 SIGTERM 信号');
69+
this.shutdown('SIGTERM', 0);
70+
});
71+
72+
// 注意:SIGINT 由 useCtrlCHandler 处理,这里不重复处理
73+
// 但如果是非 UI 模式(如 print 模式),需要处理 SIGINT
74+
if (process.env.BLADE_NON_INTERACTIVE === 'true') {
75+
process.on('SIGINT', () => {
76+
logger.info('[GracefulShutdown] 收到 SIGINT 信号(非交互模式)');
77+
this.shutdown('SIGINT', 0);
78+
});
79+
}
80+
81+
this.initialized = true;
82+
logger.debug('[GracefulShutdown] 全局错误处理器已初始化');
83+
}
84+
85+
/**
86+
* 注册清理函数
87+
* 在退出时按注册的逆序执行(后注册的先执行)
88+
*/
89+
registerCleanup(handler: CleanupHandler): () => void {
90+
this.cleanupHandlers.push(handler);
91+
logger.debug(
92+
`[GracefulShutdown] 注册清理函数,当前共 ${this.cleanupHandlers.length} 个`
93+
);
94+
95+
// 返回取消注册的函数
96+
return () => {
97+
const index = this.cleanupHandlers.indexOf(handler);
98+
if (index !== -1) {
99+
this.cleanupHandlers.splice(index, 1);
100+
logger.debug(
101+
`[GracefulShutdown] 取消注册清理函数,剩余 ${this.cleanupHandlers.length} 个`
102+
);
103+
}
104+
};
105+
}
106+
107+
/**
108+
* 处理致命错误
109+
*/
110+
private handleFatalError(type: ExitReason, error: Error): void {
111+
// 防止递归错误
112+
if (this.isShuttingDown) {
113+
console.error(`[GracefulShutdown] 退出过程中发生额外错误 (${type}):`, error);
114+
return;
115+
}
116+
117+
console.error('');
118+
console.error('═'.repeat(60));
119+
console.error(`💥 发生未捕获的错误 (${type})`);
120+
console.error('═'.repeat(60));
121+
console.error('');
122+
console.error('错误信息:', error.message);
123+
console.error('');
124+
if (error.stack) {
125+
console.error('堆栈跟踪:');
126+
console.error(error.stack);
127+
}
128+
console.error('');
129+
console.error('═'.repeat(60));
130+
console.error('');
131+
132+
// 执行清理并退出
133+
this.shutdown(type, 1);
134+
}
135+
136+
/**
137+
* 执行优雅退出
138+
*/
139+
async shutdown(reason: ExitReason, exitCode: number = 0): Promise<void> {
140+
if (this.isShuttingDown) {
141+
logger.debug('[GracefulShutdown] 已在退出过程中,跳过重复退出');
142+
return;
143+
}
144+
145+
this.isShuttingDown = true;
146+
147+
logger.info(`[GracefulShutdown] 开始优雅退出 (原因: ${reason})`);
148+
149+
// 设置超时保护,防止清理函数卡住
150+
const timeoutMs = 5000;
151+
const timeoutPromise = new Promise<void>((_, reject) => {
152+
setTimeout(() => {
153+
reject(new Error(`清理超时 (${timeoutMs}ms)`));
154+
}, timeoutMs);
155+
});
156+
157+
try {
158+
// 按逆序执行清理函数(后注册的先执行)
159+
const cleanupPromise = this.runCleanupHandlers();
160+
161+
await Promise.race([cleanupPromise, timeoutPromise]);
162+
163+
logger.info('[GracefulShutdown] 所有清理函数执行完成');
164+
} catch (error) {
165+
console.error('[GracefulShutdown] 清理过程中发生错误:', error);
166+
} finally {
167+
// 给 Ink 一点时间完成终端清理
168+
setTimeout(() => {
169+
process.exit(exitCode);
170+
}, 100);
171+
}
172+
}
173+
174+
/**
175+
* 执行所有清理函数
176+
*/
177+
private async runCleanupHandlers(): Promise<void> {
178+
// 逆序执行
179+
const handlers = [...this.cleanupHandlers].reverse();
180+
181+
for (const handler of handlers) {
182+
try {
183+
const result = handler();
184+
if (result instanceof Promise) {
185+
await result;
186+
}
187+
} catch (error) {
188+
console.error('[GracefulShutdown] 清理函数执行失败:', error);
189+
// 继续执行其他清理函数
190+
}
191+
}
192+
}
193+
194+
/**
195+
* 检查是否正在退出
196+
*/
197+
isExiting(): boolean {
198+
return this.isShuttingDown;
199+
}
200+
201+
/**
202+
* 重置状态(仅用于测试)
203+
*/
204+
reset(): void {
205+
this.isShuttingDown = false;
206+
this.cleanupHandlers = [];
207+
this.initialized = false;
208+
}
209+
}
210+
211+
// 导出单例获取函数
212+
export const getGracefulShutdown = (): GracefulShutdownManager => {
213+
return GracefulShutdownManager.getInstance();
214+
};
215+
216+
// 导出便捷函数
217+
export const registerCleanup = (handler: CleanupHandler): (() => void) => {
218+
return getGracefulShutdown().registerCleanup(handler);
219+
};
220+
221+
export const initializeGracefulShutdown = (): void => {
222+
getGracefulShutdown().initialize();
223+
};
224+
225+
export const isExiting = (): boolean => {
226+
return getGracefulShutdown().isExiting();
227+
};

src/tools/builtin/shell/BackgroundShellManager.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,24 @@ export class BackgroundShellManager {
199199
signal: processInfo.signal,
200200
};
201201
}
202+
203+
/**
204+
* 终止所有后台进程
205+
* 在应用退出时调用
206+
*/
207+
killAll(): void {
208+
for (const [_shellId, processInfo] of this.processes) {
209+
if (processInfo.status === 'running' && processInfo.process) {
210+
try {
211+
processInfo.process.kill('SIGTERM');
212+
processInfo.status = 'killed';
213+
processInfo.endTime = Date.now();
214+
processInfo.process = undefined;
215+
} catch {
216+
// 忽略终止失败(进程可能已退出)
217+
}
218+
}
219+
}
220+
this.processes.clear();
221+
}
202222
}

0 commit comments

Comments
 (0)