Skip to content

Commit 41754ba

Browse files
王璨claude
andcommitted
fix: restore clean Ctrl+C shutdown after examples
Remove the forced success-path exit, make TUI SIGINT teardown single-run, and harden MCP stdio child cleanup so example sessions restore the terminal correctly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9b591ec commit 41754ba

4 files changed

Lines changed: 71 additions & 51 deletions

File tree

src/core/harness.ts

Lines changed: 55 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export class Harness {
3434
private tui!: TuiApp;
3535
private baseSystemPrompt = "";
3636
private lastMcpProgress = new Map<string, { progress?: number; total?: number; message?: string }>();
37+
private mcpEventUnsubscribe?: () => void;
38+
private shuttingDown = false;
3739

3840
constructor(config: HarnessConfig) {
3941
this.config = config;
@@ -143,57 +145,60 @@ export class Harness {
143145

144146
await this.tui.start();
145147

146-
if (this.config.mcp.length > 0) {
147-
this.tui.addInfo(`Connecting ${this.config.mcp.length} MCP server(s)...`);
148-
this.mcpManager = new MCPManager(this.config.mcp);
149-
this.tui.setMcpManager(this.mcpManager);
150-
await this.mcpManager.initialize();
151-
this.mcpManager.onEvent((event) => this.handleMcpEvent(event));
152-
await this.mcpManager.registerDrivers(this.driverRegistry);
148+
try {
149+
if (this.config.mcp.length > 0) {
150+
this.tui.addInfo(`Connecting ${this.config.mcp.length} MCP server(s)...`);
151+
this.mcpManager = new MCPManager(this.config.mcp);
152+
this.tui.setMcpManager(this.mcpManager);
153+
await this.mcpManager.initialize();
154+
this.mcpEventUnsubscribe = this.mcpManager.onEvent((event) => this.handleMcpEvent(event));
155+
await this.mcpManager.registerDrivers(this.driverRegistry);
156+
157+
if (this.appHostManager) {
158+
this.appHostManager.setMcpManager(this.mcpManager);
159+
}
153160

154-
if (this.appHostManager) {
155-
this.appHostManager.setMcpManager(this.mcpManager);
161+
const appOnlyNames = new Set(this.mcpManager.getAppOnlyToolNames());
162+
this.toolRegistry.initialize(
163+
this.makeSkillTool(),
164+
this.mcpManager.getAlwaysLoadToolNames(),
165+
appOnlyNames,
166+
);
167+
this.agent.state.tools = this.toolRegistry.buildToolsForRequest();
168+
const connected = this.mcpManager.getStates().filter((s) => s.status === "connected").length;
169+
const total = this.config.mcp.length;
170+
this.tui.addInfo(`MCP: ${connected}/${total} connected`);
171+
if (connected < total) {
172+
const errors = this.mcpManager.getStates().filter((s) => s.status === "error");
173+
for (const err of errors) {
174+
this.tui.addInfo(`MCP '${err.config.name}' failed: ${err.error ?? "unknown"}`);
175+
}
176+
}
177+
this.tui.focusEditor();
178+
} else {
179+
this.toolRegistry.initialize(this.makeSkillTool());
180+
this.agent.state.tools = this.toolRegistry.buildToolsForRequest();
156181
}
157182

158-
const appOnlyNames = new Set(this.mcpManager.getAppOnlyToolNames());
159-
this.toolRegistry.initialize(
160-
this.makeSkillTool(),
161-
this.mcpManager.getAlwaysLoadToolNames(),
162-
appOnlyNames,
163-
);
164-
this.agent.state.tools = this.toolRegistry.buildToolsForRequest();
165-
const connected = this.mcpManager.getStates().filter((s) => s.status === "connected").length;
166-
const total = this.config.mcp.length;
167-
this.tui.addInfo(`MCP: ${connected}/${total} connected`);
168-
if (connected < total) {
169-
const errors = this.mcpManager.getStates().filter((s) => s.status === "error");
170-
for (const err of errors) {
171-
this.tui.addInfo(`MCP '${err.config.name}' failed: ${err.error ?? "unknown"}`);
172-
}
183+
if (!this.config.apiKey) {
184+
this.tui.addInfo([
185+
"Welcome to DSCode! To get started, configure your API key:",
186+
"",
187+
" /config key sk-your-deepseek-api-key",
188+
"",
189+
"Then set your preferred model:",
190+
"",
191+
" /config model deepseek-v4-pro",
192+
"",
193+
"Type /config to see all settings.",
194+
].join("\n"));
173195
}
174-
this.tui.focusEditor();
175-
} else {
176-
this.toolRegistry.initialize(this.makeSkillTool());
177-
this.agent.state.tools = this.toolRegistry.buildToolsForRequest();
178-
}
179196

180-
if (!this.config.apiKey) {
181-
this.tui.addInfo([
182-
"Welcome to DSCode! To get started, configure your API key:",
183-
"",
184-
" /config key sk-your-deepseek-api-key",
185-
"",
186-
"Then set your preferred model:",
187-
"",
188-
" /config model deepseek-v4-pro",
189-
"",
190-
"Type /config to see all settings.",
191-
].join("\n"));
197+
this.tui.focusEditor();
198+
await this.tui.waitForExit();
199+
} finally {
200+
await this.shutdown();
192201
}
193-
194-
this.tui.focusEditor();
195-
await this.tui.waitForExit();
196-
await this.shutdown();
197202
}
198203

199204
setModel(modelId: string): void {
@@ -217,6 +222,11 @@ export class Harness {
217222
}
218223

219224
private async shutdown(): Promise<void> {
225+
if (this.shuttingDown) return;
226+
this.shuttingDown = true;
227+
228+
this.mcpEventUnsubscribe?.();
229+
this.mcpEventUnsubscribe = undefined;
220230
this.sessionManager.saveSession(this.agent);
221231
if (this.appHostManager) {
222232
await this.appHostManager.shutdown();

src/core/main.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ async function main(): Promise<void> {
4242
const harness = new Harness(config);
4343
await harness.initialize();
4444
await harness.run();
45-
process.exit(0);
4645
}
4746

4847
main().catch((err) => {

src/mcp/client.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { spawn, type ChildProcess } from "node:child_process";
2-
import { createInterface } from "node:readline";
2+
import { createInterface, type Interface } from "node:readline";
33
import { request as httpRequest } from "node:http";
44
import { request as httpsRequest } from "node:https";
55
import { homedir } from "node:os";
@@ -49,6 +49,7 @@ export class MCPClient {
4949
private requestId = 0;
5050
private pending = new Map<string | number, PendingEntry>();
5151
private closed = false;
52+
private closing = false;
5253
private sseUrl: string | null = null;
5354
private sessionId: string | null = null;
5455
private protocolVersion: MCPProtocolVersion;
@@ -58,6 +59,7 @@ export class MCPClient {
5859
private toolDefs = new Map<string, MCPToolDefinition>();
5960
private eventListeners = new Set<(event: MCPClientEvent) => void>();
6061
private activeSseRequest: ReturnType<typeof httpRequest> | ReturnType<typeof httpsRequest> | null = null;
62+
private stdioReadline: Interface | null = null;
6163

6264
constructor(private config: MCPServerConfig) {
6365
this.protocolVersion = config.preferredProtocolVersion ?? DEFAULT_MCP_PROTOCOL_VERSION;
@@ -177,7 +179,8 @@ export class MCPClient {
177179
}
178180

179181
async close(): Promise<void> {
180-
if (this.closed) return;
182+
if (this.closed || this.closing) return;
183+
this.closing = true;
181184
this.closed = true;
182185

183186
if (this.resolvedTransport === "stdio") {
@@ -191,13 +194,17 @@ export class MCPClient {
191194
this.activeSseRequest = null;
192195
}
193196

197+
this.stdioReadline?.close();
198+
this.stdioReadline = null;
199+
194200
for (const [, entry] of this.pending) {
195201
clearTimeout(entry.timer);
196202
entry.reject(new Error("MCP client closed"));
197203
}
198204
this.pending.clear();
199205

200206
if (this.process) {
207+
this.process.removeAllListeners();
201208
this.process = null;
202209
}
203210
}
@@ -306,12 +313,13 @@ export class MCPClient {
306313
});
307314
});
308315

309-
const rl = createInterface({ input: this.process.stdout! });
310-
rl.on("line", (line) => {
316+
this.stdioReadline = createInterface({ input: this.process.stdout! });
317+
this.stdioReadline.on("line", (line) => {
311318
this.handleMessage(line);
312319
});
313320

314321
this.process.on("exit", (code) => {
322+
if (this.closing) return;
315323
if (!this.closed) {
316324
this.closed = true;
317325
setImmediate(() => {
@@ -323,6 +331,7 @@ export class MCPClient {
323331
});
324332

325333
this.process.on("error", (err) => {
334+
if (this.closing) return;
326335
if (!this.closed) {
327336
this.closed = true;
328337
this.rejectAllPending(new Error(`MCP server "${this.config.name}" error: ${err.message}`));

src/ui/tui-app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export class TuiApp {
8888
private exitResolve!: () => void;
8989
private stopping = false;
9090
private pendingImages: ImageContent[] = [];
91+
private sigintHandler = () => this.handleCtrlC();
9192

9293
constructor(deps: TuiDeps) {
9394
this.deps = deps;
@@ -124,7 +125,7 @@ export class TuiApp {
124125
return undefined;
125126
});
126127

127-
process.on("SIGINT", () => this.handleCtrlC());
128+
process.on("SIGINT", this.sigintHandler);
128129

129130
this.loader.onAbort = () => {
130131
deps.agent.abort();
@@ -757,6 +758,7 @@ export class TuiApp {
757758
stop(): void {
758759
if (this.stopping) return;
759760
this.stopping = true;
761+
process.removeListener("SIGINT", this.sigintHandler);
760762
this.terminal.write("\n" + c.dim("Goodbye.\n"));
761763
this.tui.stop();
762764
this.exitResolve();

0 commit comments

Comments
 (0)