Skip to content

Commit 4e527c5

Browse files
cameroncookecodex
andcommitted
fix(mcp): Bound shutdown cleanup budgets and race handling
Scale bulk cleanup step timeouts to the tracked item counts so sequential\ncleanup work is not prematurely cut off by the outer step timer.\n\nHarden runStep timeout racing by consuming late operation rejections and\nreturning a structured failed outcome instead of risking unhandled\nrejections during shutdown.\n\nTrack the parent hard-exit helper script used by the lifecycle repro so\n--shutdown-mode parent-hard-exit works from a clean checkout.\n\nAdd shutdown regression coverage for expanded bulk timeout budgeting. Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent c4ece28 commit 4e527c5

3 files changed

Lines changed: 131 additions & 24 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { spawn } from 'node:child_process';
2+
import process from 'node:process';
3+
4+
const [nodeExecPath, cliPath, cwd, exitDelayMsRaw] = process.argv.slice(2);
5+
const exitDelayMs = Number(exitDelayMsRaw ?? 0);
6+
7+
if (!nodeExecPath || !cliPath || !cwd || !Number.isFinite(exitDelayMs) || exitDelayMs < 0) {
8+
console.error('Usage: node repro-mcp-parent-exit-helper.mjs <nodeExecPath> <cliPath> <cwd> <exitDelayMs>');
9+
process.exit(2);
10+
}
11+
12+
const child = spawn(nodeExecPath, [cliPath, 'mcp'], {
13+
cwd,
14+
stdio: ['pipe', 'pipe', 'pipe'],
15+
});
16+
17+
process.stdout.write(`${child.pid}\n`);
18+
19+
setTimeout(() => {
20+
process.exit(0);
21+
}, exitDelayMs);

src/server/__tests__/mcp-shutdown.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ vi.mock('../../utils/shutdown-state.ts', () => ({
5656

5757
import { runMcpShutdown } from '../mcp-shutdown.ts';
5858

59+
function wait(ms: number): Promise<void> {
60+
return new Promise((resolve) => setTimeout(resolve, ms));
61+
}
62+
5963
describe('runMcpShutdown', () => {
6064
beforeEach(() => {
6165
vi.clearAllMocks();
@@ -101,4 +105,41 @@ describe('runMcpShutdown', () => {
101105
expect(mocks.stopAllVideoCaptureSessions).toHaveBeenCalledTimes(1);
102106
expect(mocks.stopAllTrackedProcesses).toHaveBeenCalledTimes(1);
103107
});
108+
109+
it('uses an expanded timeout budget for sequential bulk cleanup steps', async () => {
110+
mocks.stopAllLogCaptures.mockImplementationOnce(async () => {
111+
await wait(1100);
112+
return { stoppedSessionCount: 2, errorCount: 0, errors: [] };
113+
});
114+
115+
const result = await runMcpShutdown({
116+
reason: 'sigterm',
117+
snapshot: {
118+
pid: 1,
119+
ppid: 1,
120+
orphaned: false,
121+
phase: 'running',
122+
shutdownReason: 'sigterm',
123+
uptimeMs: 100,
124+
rssBytes: 1,
125+
heapUsedBytes: 1,
126+
watcherRunning: false,
127+
watchedPath: null,
128+
activeOperationCount: 0,
129+
activeOperationByCategory: {},
130+
debuggerSessionCount: 0,
131+
simulatorLogSessionCount: 2,
132+
deviceLogSessionCount: 0,
133+
videoCaptureSessionCount: 0,
134+
swiftPackageProcessCount: 0,
135+
matchingMcpProcessCount: 0,
136+
matchingMcpPeerSummary: [],
137+
anomalies: [],
138+
},
139+
server: { close: async () => undefined },
140+
});
141+
142+
const simulatorLogsStep = result.steps.find((step) => step.name === 'simulator-logs.stop-all');
143+
expect(simulatorLogsStep?.status).toBe('completed');
144+
});
104145
});

src/server/mcp-shutdown.ts

Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ interface ShutdownStepOutcome<T> {
3737
error?: string;
3838
}
3939

40+
type RunStepRaceOutcome<T> =
41+
| { kind: 'value'; value: T }
42+
| { kind: 'error'; error: string }
43+
| { kind: 'timed_out' };
44+
4045
export interface McpShutdownResult {
4146
exitCode: number;
4247
transportDisconnected: boolean;
@@ -63,14 +68,19 @@ async function runStep<T>(
6368
let timeoutHandle: NodeJS.Timeout | null = null;
6469

6570
try {
66-
const timeoutPromise = new Promise<'timed_out'>((resolve) => {
67-
timeoutHandle = createTimer(timeoutMs, () => resolve('timed_out'));
71+
const timeoutPromise = new Promise<RunStepRaceOutcome<T>>((resolve) => {
72+
timeoutHandle = createTimer(timeoutMs, () => resolve({ kind: 'timed_out' }));
6873
});
69-
const operationPromise = operation();
70-
const outcome = await Promise.race([
71-
operationPromise.then((value) => ({ kind: 'value' as const, value })),
72-
timeoutPromise.then(() => ({ kind: 'timed_out' as const })),
73-
]);
74+
75+
const operationOutcome = operation()
76+
.then((value): RunStepRaceOutcome<T> => ({ kind: 'value', value }))
77+
.catch(
78+
(error): RunStepRaceOutcome<T> => ({
79+
kind: 'error',
80+
error: stringifyError(error),
81+
}),
82+
);
83+
const outcome = await Promise.race([operationOutcome, timeoutPromise]);
7484

7585
if (outcome.kind === 'timed_out') {
7686
return {
@@ -79,17 +89,19 @@ async function runStep<T>(
7989
};
8090
}
8191

92+
if (outcome.kind === 'error') {
93+
return {
94+
status: 'failed',
95+
durationMs: Date.now() - startedAt,
96+
error: outcome.error,
97+
};
98+
}
99+
82100
return {
83101
status: 'completed',
84102
durationMs: Date.now() - startedAt,
85103
value: outcome.value,
86104
};
87-
} catch (error) {
88-
return {
89-
status: 'failed',
90-
durationMs: Date.now() - startedAt,
91-
error: stringifyError(error),
92-
};
93105
} finally {
94106
if (timeoutHandle) {
95107
clearTimeout(timeoutHandle);
@@ -152,19 +164,52 @@ export async function runMcpShutdown(input: {
152164
});
153165
pushStep('server.close', serverCloseOutcome);
154166

155-
const cleanupSteps: Array<[string, () => Promise<unknown>]> = [
156-
['watcher.stop', () => stopXcodeStateWatcher()],
157-
['xcode-tools-bridge.shutdown', () => shutdownXcodeToolsBridge()],
158-
['debugger.dispose-all', () => getDefaultDebuggerManager().disposeAll()],
159-
['simulator-logs.stop-all', () => stopAllLogCaptures(STEP_TIMEOUT_MS)],
160-
['device-logs.stop-all', () => stopAllDeviceLogCaptures(STEP_TIMEOUT_MS)],
161-
['video-capture.stop-all', () => stopAllVideoCaptureSessions(STEP_TIMEOUT_MS)],
162-
['swift-processes.stop-all', () => stopAllTrackedProcesses(STEP_TIMEOUT_MS)],
167+
const perItemTimeoutMs = STEP_TIMEOUT_MS;
168+
const bulkStepTimeoutMs = (itemCount: number): number => {
169+
return Math.max(STEP_TIMEOUT_MS, itemCount * perItemTimeoutMs);
170+
};
171+
172+
const cleanupSteps: Array<{
173+
name: string;
174+
timeoutMs: number;
175+
operation: () => Promise<unknown>;
176+
}> = [
177+
{ name: 'watcher.stop', timeoutMs: STEP_TIMEOUT_MS, operation: () => stopXcodeStateWatcher() },
178+
{
179+
name: 'xcode-tools-bridge.shutdown',
180+
timeoutMs: STEP_TIMEOUT_MS,
181+
operation: () => shutdownXcodeToolsBridge(),
182+
},
183+
{
184+
name: 'debugger.dispose-all',
185+
timeoutMs: STEP_TIMEOUT_MS,
186+
operation: () => getDefaultDebuggerManager().disposeAll(),
187+
},
188+
{
189+
name: 'simulator-logs.stop-all',
190+
timeoutMs: bulkStepTimeoutMs(input.snapshot.simulatorLogSessionCount),
191+
operation: () => stopAllLogCaptures(perItemTimeoutMs),
192+
},
193+
{
194+
name: 'device-logs.stop-all',
195+
timeoutMs: bulkStepTimeoutMs(input.snapshot.deviceLogSessionCount),
196+
operation: () => stopAllDeviceLogCaptures(perItemTimeoutMs),
197+
},
198+
{
199+
name: 'video-capture.stop-all',
200+
timeoutMs: bulkStepTimeoutMs(input.snapshot.videoCaptureSessionCount),
201+
operation: () => stopAllVideoCaptureSessions(perItemTimeoutMs),
202+
},
203+
{
204+
name: 'swift-processes.stop-all',
205+
timeoutMs: bulkStepTimeoutMs(input.snapshot.swiftPackageProcessCount),
206+
operation: () => stopAllTrackedProcesses(perItemTimeoutMs),
207+
},
163208
];
164209

165-
for (const [name, operation] of cleanupSteps) {
166-
const outcome = await runStep(name, STEP_TIMEOUT_MS, operation);
167-
pushStep(name, outcome);
210+
for (const cleanupStep of cleanupSteps) {
211+
const outcome = await runStep(cleanupStep.name, cleanupStep.timeoutMs, cleanupStep.operation);
212+
pushStep(cleanupStep.name, outcome);
168213
}
169214

170215
const triggerError = input.error === undefined ? undefined : stringifyError(input.error);

0 commit comments

Comments
 (0)