diff --git a/.gitignore b/.gitignore index b1bb9f21..39bb0601 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,4 @@ bundled/ DerivedData .derivedData /.pr-learning +/repros diff --git a/.vscode/mcp.json b/.vscode/mcp.json index ac5ac9c3..8236aa3d 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,11 +1,12 @@ -{ +{ "servers": { "XcodeBuildMCP": { "type": "stdio", "command": "npx", "args": [ "-y", - "xcodebuildmcp@latest" + "xcodebuildmcp@latest", + "mcp" ], "env": { "XCODEBUILDMCP_DEBUG": "true", diff --git a/CHANGELOG.md b/CHANGELOG.md index 559ce30b..01cbd95e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ - Clarified configuration layering: `session_set_defaults` overrides `config.yaml`, which overrides env-based bootstrap values. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). +### Fixed + +- Fixed orphaned MCP server processes by attaching shutdown handlers before async startup, explicitly stopping the Xcode watcher during teardown, and adding lifecycle diagnostics for memory and peer-process anomalies ([#273](https://github.com/getsentry/XcodeBuildMCP/issues/273)). + ## [2.2.1] - Fix AXe bundling issue. @@ -406,4 +410,3 @@ Please note that the UI automation features are an early preview and currently i ## [v1.0.1] - 2025-04-02 - Initial release of XcodeBuildMCP - Basic support for building iOS and macOS applications - diff --git a/package-lock.json b/package-lock.json index 1d824189..ca7831d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,9 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.25.1", + "@modelcontextprotocol/sdk": "^1.27.1", "@sentry/cli": "^3.1.0", - "@sentry/node": "^10.38.0", + "@sentry/node": "^10.43.0", "bplist-parser": "^0.3.2", "chokidar": "^5.0.0", "uuid": "^11.1.0", @@ -63,23 +63,6 @@ "node": ">=6.0.0" } }, - "node_modules/@apm-js-collab/code-transformer": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz", - "integrity": "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==", - "license": "Apache-2.0" - }, - "node_modules/@apm-js-collab/tracing-hooks": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.3.1.tgz", - "integrity": "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==", - "license": "Apache-2.0", - "dependencies": { - "@apm-js-collab/code-transformer": "^0.8.0", - "debug": "^4.4.1", - "module-details-from-path": "^1.0.4" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -789,6 +772,96 @@ "xmlbuilder": "^14.0.0" } }, + "node_modules/@fastify/otel": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz", + "integrity": "sha512-2304BdM5Q/kUvQC9qJO1KZq3Zn1WWsw+WWkVmFEaj1UE2hEIiuFqrPeglQOwEtw/ftngisqfQ3v70TWMmwhhHA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.28.0", + "minimatch": "^10.0.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", + "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@fastify/otel/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@fastify/otel/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@fastify/otel/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@hono/node-server": { "version": "1.19.10", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz", @@ -935,9 +1008,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -1056,9 +1129,9 @@ } }, "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.0.tgz", - "integrity": "sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz", + "integrity": "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==", "license": "Apache-2.0", "engines": { "node": "^18.19.0 || >=20.6.0" @@ -1476,12 +1549,12 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", - "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", + "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.0", + "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -1491,14 +1564,29 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", - "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", + "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/resources": "2.5.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -1508,6 +1596,21 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.39.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", @@ -2128,23 +2231,24 @@ } }, "node_modules/@sentry/core": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.38.0.tgz", - "integrity": "sha512-1pubWDZE5y5HZEPMAZERP4fVl2NH3Ihp1A+vMoVkb3Qc66Diqj1WierAnStlZP7tCx0TBa0dK85GTW/ZFYyB9g==", + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.43.0.tgz", + "integrity": "sha512-l0SszQAPiQGWl/ferw8GP3ALyHXiGiRKJaOvNmhGO+PrTQyZTZ6OYyPnGijAFRg58dE1V3RCH/zw5d2xSUIiNg==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/node": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.38.0.tgz", - "integrity": "sha512-wriyDtWDAoatn8EhOj0U4PJR1WufiijTsCGALqakOHbFiadtBJANLe6aSkXoXT4tegw59cz1wY4NlzHjYksaPw==", + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.43.0.tgz", + "integrity": "sha512-oNwXcuZUc4uTTr0WbHZBBIKsKwAKvNMTgbXwxfB37CfzV18wbTirbQABZ/Ir3WNxSgi6ZcnC6UE013jF5XWPqw==", "license": "MIT", "dependencies": { + "@fastify/otel": "0.16.0", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.5.0", - "@opentelemetry/core": "^2.5.0", + "@opentelemetry/context-async-hooks": "^2.5.1", + "@opentelemetry/core": "^2.5.1", "@opentelemetry/instrumentation": "^0.211.0", "@opentelemetry/instrumentation-amqplib": "0.58.0", "@opentelemetry/instrumentation-connect": "0.54.0", @@ -2168,29 +2272,27 @@ "@opentelemetry/instrumentation-redis": "0.59.0", "@opentelemetry/instrumentation-tedious": "0.30.0", "@opentelemetry/instrumentation-undici": "0.21.0", - "@opentelemetry/resources": "^2.5.0", - "@opentelemetry/sdk-trace-base": "^2.5.0", + "@opentelemetry/resources": "^2.5.1", + "@opentelemetry/sdk-trace-base": "^2.5.1", "@opentelemetry/semantic-conventions": "^1.39.0", "@prisma/instrumentation": "7.2.0", - "@sentry/core": "10.38.0", - "@sentry/node-core": "10.38.0", - "@sentry/opentelemetry": "10.38.0", - "import-in-the-middle": "^2.0.6", - "minimatch": "^9.0.0" + "@sentry/core": "10.43.0", + "@sentry/node-core": "10.43.0", + "@sentry/opentelemetry": "10.43.0", + "import-in-the-middle": "^2.0.6" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/node-core": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.38.0.tgz", - "integrity": "sha512-ErXtpedrY1HghgwM6AliilZPcUCoNNP1NThdO4YpeMq04wMX9/GMmFCu46TnCcg6b7IFIOSr2S4yD086PxLlHQ==", + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.43.0.tgz", + "integrity": "sha512-w2H3NSkNMoYOS7o7mR55BM7+xL++dPxMSv1/XDfsra9FYHGppO+Mxk667Ee5k+uDi+wNIioICIh+5XOvZh4+HQ==", "license": "MIT", "dependencies": { - "@apm-js-collab/tracing-hooks": "^0.3.1", - "@sentry/core": "10.38.0", - "@sentry/opentelemetry": "10.38.0", + "@sentry/core": "10.43.0", + "@sentry/opentelemetry": "10.43.0", "import-in-the-middle": "^2.0.6" }, "engines": { @@ -2204,51 +2306,53 @@ "@opentelemetry/resources": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" - } - }, - "node_modules/@sentry/node/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@sentry/node/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" }, - "engines": { - "node": "18 || 20 || >=22" + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/context-async-hooks": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } } }, - "node_modules/@sentry/node/node_modules/minimatch": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", - "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", - "license": "ISC", + "node_modules/@sentry/node/node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^5.0.2" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "node_modules/@sentry/opentelemetry": { - "version": "10.38.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.38.0.tgz", - "integrity": "sha512-YPVhWfYmC7nD3EJqEHGtjp4fp5LwtAbE5rt9egQ4hqJlYFvr8YEz9sdoqSZxO0cZzgs2v97HFl/nmWAXe52G2Q==", + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.43.0.tgz", + "integrity": "sha512-+fIcnnLdvBHdq4nKq23t9v/B9D4L97fPWEDksXbpGs11o6BsqY4Tlzmce6cP95iiQhPckCEag3FthSND+BYtYQ==", "license": "MIT", "dependencies": { - "@sentry/core": "10.38.0" + "@sentry/core": "10.43.0" }, "engines": { "node": ">=18" diff --git a/package.json b/package.json index 1df71f3e..3e1910ef 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "prepare": "node scripts/install-git-hooks.js", "hooks:install": "node scripts/install-git-hooks.js", "generate:version": "npx tsx scripts/generate-version.ts", + "repro:mcp-lifecycle-leak": "npm run build && npx tsx scripts/repro-mcp-lifecycle-leak.ts", "bundle:axe": "scripts/bundle-axe.sh", "package:macos": "scripts/package-macos-portable.sh", "package:macos:universal": "scripts/package-macos-portable.sh --universal", @@ -73,9 +74,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.1", - "@modelcontextprotocol/sdk": "^1.25.1", + "@modelcontextprotocol/sdk": "^1.27.1", "@sentry/cli": "^3.1.0", - "@sentry/node": "^10.38.0", + "@sentry/node": "^10.43.0", "bplist-parser": "^0.3.2", "chokidar": "^5.0.0", "uuid": "^11.1.0", diff --git a/scripts/repro-mcp-lifecycle-leak.ts b/scripts/repro-mcp-lifecycle-leak.ts new file mode 100644 index 00000000..a7233b92 --- /dev/null +++ b/scripts/repro-mcp-lifecycle-leak.ts @@ -0,0 +1,225 @@ +import { spawn } from 'node:child_process'; +import process from 'node:process'; + +interface CliOptions { + iterations: number; + closeDelayMs: number; + settleMs: number; +} + +interface PeerProcess { + pid: number; + ageSeconds: number; + rssKb: number; + command: string; +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + iterations: 20, + closeDelayMs: 0, + settleMs: 2000, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const value = argv[index + 1]; + + if (arg === '--iterations' && value) { + options.iterations = Number(value); + index += 1; + } else if (arg === '--close-delay-ms' && value) { + options.closeDelayMs = Number(value); + index += 1; + } else if (arg === '--settle-ms' && value) { + options.settleMs = Number(value); + index += 1; + } + } + + if (!Number.isFinite(options.iterations) || options.iterations < 1) { + throw new Error('--iterations must be a positive number'); + } + if (!Number.isFinite(options.closeDelayMs) || options.closeDelayMs < 0) { + throw new Error('--close-delay-ms must be a non-negative number'); + } + if (!Number.isFinite(options.settleMs) || options.settleMs < 0) { + throw new Error('--settle-ms must be a non-negative number'); + } + + return options; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isLikelyMcpCommand(command: string): boolean { + const normalized = command.toLowerCase(); + return ( + /(^|\s)mcp(\s|$)/.test(normalized) && + !/(^|\s)daemon(\s|$)/.test(normalized) && + (normalized.includes('xcodebuildmcp') || + normalized.includes('build/cli.js') || + normalized.includes('/cli.js')) + ); +} + +function parseElapsedSeconds(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const daySplit = trimmed.split('-'); + const timePart = daySplit.length === 2 ? daySplit[1] : daySplit[0]; + const dayCount = daySplit.length === 2 ? Number(daySplit[0]) : 0; + const parts = timePart.split(':').map((part) => Number(part)); + + if (!Number.isFinite(dayCount) || parts.some((part) => !Number.isFinite(part))) { + return null; + } + + if (parts.length === 1) { + return dayCount * 86400 + parts[0]; + } + if (parts.length === 2) { + return dayCount * 86400 + parts[0] * 60 + parts[1]; + } + if (parts.length === 3) { + return dayCount * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + + return null; +} + +async function sampleMcpProcesses(): Promise { + return new Promise((resolve, reject) => { + const child = spawn('ps', ['-axo', 'pid=,etime=,rss=,command='], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(stderr || `ps exited with code ${code}`)); + return; + } + + const processes = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const match = line.match(/^(\d+)\s+(\S+)\s+(\d+)\s+(.+)$/); + if (!match) { + return null; + } + const ageSeconds = parseElapsedSeconds(match[2]); + return { + pid: Number(match[1]), + ageSeconds, + rssKb: Number(match[3]), + command: match[4], + }; + }) + .filter((entry): entry is PeerProcess => { + return ( + entry !== null && + Number.isFinite(entry.pid) && + Number.isFinite(entry.ageSeconds) && + Number.isFinite(entry.rssKb) && + isLikelyMcpCommand(entry.command) + ); + }); + + resolve(processes); + }); + }); +} + +async function runIteration(closeDelayMs: number): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, ['build/cli.js', 'mcp'], { + cwd: process.cwd(), + stdio: ['pipe', 'ignore', 'ignore'], + }); + + let exited = false; + child.once('close', () => { + exited = true; + resolve(true); + }); + child.once('error', () => { + exited = true; + resolve(false); + }); + + setTimeout(() => { + child.stdin.end(); + }, closeDelayMs); + + setTimeout( + () => { + if (!exited) { + resolve(false); + } + }, + Math.max(1000, closeDelayMs + 1000), + ); + }); +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const before = await sampleMcpProcesses(); + const baselinePids = new Set(before.map((entry) => entry.pid)); + + let exitedCount = 0; + for (let index = 0; index < options.iterations; index += 1) { + const exited = await runIteration(options.closeDelayMs); + if (exited) { + exitedCount += 1; + } + } + + await delay(options.settleMs); + + const after = await sampleMcpProcesses(); + const lingering = after.filter((entry) => !baselinePids.has(entry.pid)); + + console.log( + JSON.stringify( + { + iterations: options.iterations, + exitedCount, + baselineProcessCount: before.length, + finalProcessCount: after.length, + lingeringProcessCount: lingering.length, + lingering: lingering.map(({ pid, ageSeconds, rssKb, command }) => ({ + pid, + ageSeconds, + rssKb, + command, + })), + }, + null, + 2, + ), + ); + + process.exit(lingering.length === 0 ? 0 : 1); +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/src/mcp/resources/__tests__/session-status.test.ts b/src/mcp/resources/__tests__/session-status.test.ts index 433305df..a074d230 100644 --- a/src/mcp/resources/__tests__/session-status.test.ts +++ b/src/mcp/resources/__tests__/session-status.test.ts @@ -1,19 +1,25 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { clearDaemonActivityRegistry } from '../../../daemon/activity-registry.ts'; import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import { activeLogSessions } from '../../../utils/log_capture.ts'; import { activeDeviceLogSessions } from '../../../utils/log-capture/device-log-sessions.ts'; +import { clearAllProcesses } from '../../tools/swift-package/active-processes.ts'; import sessionStatusResource, { sessionStatusResourceLogic } from '../session-status.ts'; describe('session-status resource', () => { beforeEach(async () => { activeLogSessions.clear(); activeDeviceLogSessions.clear(); + clearAllProcesses(); + clearDaemonActivityRegistry(); await getDefaultDebuggerManager().disposeAll(); }); afterEach(async () => { activeLogSessions.clear(); activeDeviceLogSessions.clear(); + clearAllProcesses(); + clearDaemonActivityRegistry(); await getDefaultDebuggerManager().disposeAll(); }); @@ -48,6 +54,14 @@ describe('session-status resource', () => { expect(parsed.logging.device.activeSessionIds).toEqual([]); expect(parsed.debug.currentSessionId).toBe(null); expect(parsed.debug.sessionIds).toEqual([]); + expect(parsed.watcher).toEqual({ running: false, watchedPath: null }); + expect(parsed.video.activeSessionIds).toEqual([]); + expect(parsed.swiftPackage.activePids).toEqual([]); + expect(parsed.activity).toEqual({ activeOperationCount: 0, byCategory: {} }); + expect(parsed.process.pid).toBeTypeOf('number'); + expect(parsed.process.uptimeMs).toBeTypeOf('number'); + expect(parsed.process.rssBytes).toBeTypeOf('number'); + expect(parsed.process.heapUsedBytes).toBeTypeOf('number'); }); }); }); diff --git a/src/server/__tests__/mcp-lifecycle.test.ts b/src/server/__tests__/mcp-lifecycle.test.ts new file mode 100644 index 00000000..fb46e33f --- /dev/null +++ b/src/server/__tests__/mcp-lifecycle.test.ts @@ -0,0 +1,151 @@ +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { + buildMcpLifecycleSnapshot, + classifyMcpLifecycleAnomalies, + createMcpLifecycleCoordinator, +} from '../mcp-lifecycle.ts'; + +class TestStdin extends EventEmitter { + override once(event: string, listener: (...args: unknown[]) => void): this { + return super.once(event, listener); + } + + override removeListener(event: string, listener: (...args: unknown[]) => void): this { + return super.removeListener(event, listener); + } +} + +class TestProcess extends EventEmitter { + readonly stdin = new TestStdin(); + readonly stdout = new TestStdin(); + + override once(event: string, listener: (...args: unknown[]) => void): this { + return super.once(event, listener); + } + + override removeListener(event: string, listener: (...args: unknown[]) => void): this { + return super.removeListener(event, listener); + } +} + +describe('mcp lifecycle coordinator', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('deduplicates shutdown requests from stdin end and close', async () => { + const processRef = new TestProcess(); + const onShutdown = vi.fn().mockResolvedValue(undefined); + const coordinator = createMcpLifecycleCoordinator({ + commandExecutor: createMockExecutor({ output: '' }), + processRef, + onShutdown, + }); + + coordinator.attachProcessHandlers(); + processRef.stdin.emit('end'); + processRef.stdin.emit('close'); + await vi.waitFor(() => { + expect(onShutdown).toHaveBeenCalledTimes(1); + }); + + expect(onShutdown.mock.calls[0]?.[0]?.reason).toBe('stdin-end'); + }); + + it('shuts down cleanly even if stdin closes before a server is registered', async () => { + const processRef = new TestProcess(); + const onShutdown = vi.fn().mockResolvedValue(undefined); + const coordinator = createMcpLifecycleCoordinator({ + commandExecutor: createMockExecutor({ output: '' }), + processRef, + onShutdown, + }); + + coordinator.attachProcessHandlers(); + processRef.stdin.emit('close'); + await vi.waitFor(() => { + expect(onShutdown).toHaveBeenCalledTimes(1); + }); + + expect(onShutdown.mock.calls[0]?.[0]?.server).toBe(null); + }); + + it('maps unhandled rejections to crash shutdowns', async () => { + const processRef = new TestProcess(); + const onShutdown = vi.fn().mockResolvedValue(undefined); + const coordinator = createMcpLifecycleCoordinator({ + commandExecutor: createMockExecutor({ output: '' }), + processRef, + onShutdown, + }); + + coordinator.attachProcessHandlers(); + processRef.emit('unhandledRejection', new Error('boom')); + await vi.waitFor(() => { + expect(onShutdown).toHaveBeenCalledTimes(1); + }); + + expect(onShutdown.mock.calls[0]?.[0]?.reason).toBe('unhandled-rejection'); + }); + + it('maps broken stdout pipes to shutdowns', async () => { + const processRef = new TestProcess(); + const onShutdown = vi.fn().mockResolvedValue(undefined); + const coordinator = createMcpLifecycleCoordinator({ + commandExecutor: createMockExecutor({ output: '' }), + processRef, + onShutdown, + }); + + coordinator.attachProcessHandlers(); + processRef.stdout.emit('error', Object.assign(new Error('broken pipe'), { code: 'EPIPE' })); + await vi.waitFor(() => { + expect(onShutdown).toHaveBeenCalledTimes(1); + }); + + expect(onShutdown.mock.calls[0]?.[0]?.reason).toBe('stdout-error'); + }); +}); + +describe('mcp lifecycle snapshot', () => { + it('classifies peer-count and memory anomalies', () => { + expect( + classifyMcpLifecycleAnomalies({ + uptimeMs: 10 * 60 * 1000, + rssBytes: 600 * 1024 * 1024, + matchingMcpProcessCount: 4, + matchingMcpPeerSummary: [{ pid: 11, ageSeconds: 180, rssKb: 1000 }], + }), + ).toEqual(['high-rss', 'long-lived-high-rss', 'peer-age-high', 'peer-count-high']); + }); + + it('samples matching MCP peer processes from ps output', async () => { + vi.spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 64 * 1024 * 1024, + heapTotal: 8, + heapUsed: 4, + external: 0, + arrayBuffers: 0, + }); + const startedAtMs = Date.now() - 1000; + + const snapshot = await buildMcpLifecycleSnapshot({ + phase: 'running', + shutdownReason: null, + startedAtMs, + commandExecutor: createMockExecutor({ + output: [ + `${process.pid} 00:05 65536 node /tmp/build/cli.js mcp`, + `999 03:00 1024 node /tmp/build/cli.js mcp`, + `321 00:07 2048 node /tmp/build/cli.js daemon`, + ].join('\n'), + }), + }); + + expect(snapshot.matchingMcpProcessCount).toBe(2); + expect(snapshot.matchingMcpPeerSummary).toEqual([{ pid: 999, ageSeconds: 180, rssKb: 1024 }]); + expect(snapshot.anomalies).toEqual(['peer-age-high']); + }); +}); diff --git a/src/server/__tests__/start-mcp-server.test.ts b/src/server/__tests__/start-mcp-server.test.ts new file mode 100644 index 00000000..789a6a12 --- /dev/null +++ b/src/server/__tests__/start-mcp-server.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { __closeServerForFastExitForTests } from '../start-mcp-server.ts'; + +describe('fast-exit server close', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('returns skipped when server is not available', async () => { + await expect(__closeServerForFastExitForTests(undefined)).resolves.toBe('skipped'); + }); + + it('returns closed when server close resolves quickly', async () => { + const close = vi.fn(async () => undefined); + await expect(__closeServerForFastExitForTests({ close }, 50)).resolves.toBe('closed'); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('returns rejected when server close throws', async () => { + const close = vi.fn(async () => { + throw new Error('close failed'); + }); + + await expect(__closeServerForFastExitForTests({ close }, 50)).resolves.toBe('rejected'); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('times out when server close never settles', async () => { + vi.useFakeTimers(); + const close = vi.fn(() => new Promise(() => undefined)); + + const outcomePromise = __closeServerForFastExitForTests({ close }, 50); + await vi.advanceTimersByTimeAsync(50); + + await expect(outcomePromise).resolves.toBe('timed_out'); + expect(close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 292ad189..9aafde72 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -24,7 +24,7 @@ export interface BootstrapOptions { } export interface BootstrapResult { - runDeferredInitialization: () => Promise; + runDeferredInitialization: (options?: { isShutdownRequested?: () => boolean }) => Promise; } export async function bootstrapServer( @@ -104,8 +104,9 @@ export async function bootstrapServer( profiler.mark('registerResources', stageStartMs); return { - runDeferredInitialization: async (): Promise => { + runDeferredInitialization: async (options = {}): Promise => { const deferredProfiler = createStartupProfiler('bootstrap-deferred'); + const isShutdownRequested = options.isShutdownRequested; if (!xcodeDetection.runningUnderXcode) { return; @@ -115,6 +116,10 @@ export async function bootstrapServer( const { projectPath, workspacePath } = sessionStore.getAll(); + if (isShutdownRequested?.()) { + return; + } + let deferredStageStartMs = getStartupProfileNowMs(); const xcodeState = await readXcodeIdeState({ executor, @@ -125,6 +130,10 @@ export async function bootstrapServer( }); deferredProfiler.mark('readXcodeIdeState', deferredStageStartMs); + if (isShutdownRequested?.()) { + return; + } + if (xcodeState.error) { log('debug', `[xcode] Could not read Xcode IDE state: ${xcodeState.error}`); } else { @@ -162,6 +171,9 @@ export async function bootstrapServer( } if (!result.runtime.config.disableXcodeAutoSync) { + if (isShutdownRequested?.()) { + return; + } deferredStageStartMs = getStartupProfileNowMs(); const watcherStarted = await startXcodeStateWatcher({ executor, diff --git a/src/server/mcp-lifecycle.ts b/src/server/mcp-lifecycle.ts new file mode 100644 index 00000000..9c3f15cc --- /dev/null +++ b/src/server/mcp-lifecycle.ts @@ -0,0 +1,432 @@ +import process from 'node:process'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { getDefaultDebuggerManager } from '../utils/debugger/index.ts'; +import { activeLogSessions } from '../utils/log_capture.ts'; +import { activeDeviceLogSessions } from '../utils/log-capture/device-log-sessions.ts'; +import { activeProcesses } from '../mcp/tools/swift-package/active-processes.ts'; +import { getDaemonActivitySnapshot } from '../daemon/activity-registry.ts'; +import { listActiveVideoCaptureSessionIds } from '../utils/video_capture.ts'; +import { getDefaultCommandExecutor } from '../utils/execution/index.ts'; +import type { CommandExecutor } from '../utils/execution/index.ts'; +import { getWatchedPath, isWatcherRunning } from '../utils/xcode-state-watcher.ts'; + +export type McpStartupPhase = + | 'initializing' + | 'hydrating-sentry-config' + | 'initializing-sentry' + | 'creating-server' + | 'bootstrapping-server' + | 'starting-stdio-transport' + | 'running' + | 'deferred-initialization' + | 'shutting-down' + | 'stopped'; + +export type McpShutdownReason = + | 'stdin-end' + | 'stdin-close' + | 'stdout-error' + | 'sigint' + | 'sigterm' + | 'startup-failure' + | 'uncaught-exception' + | 'unhandled-rejection'; + +export type McpLifecycleAnomaly = + | 'peer-count-high' + | 'peer-age-high' + | 'high-rss' + | 'long-lived-high-rss'; + +export interface McpPeerProcessSummary { + pid: number; + ageSeconds: number; + rssKb: number; +} + +export interface McpLifecycleSnapshot { + pid: number; + phase: McpStartupPhase; + shutdownReason: McpShutdownReason | null; + uptimeMs: number; + rssBytes: number; + heapUsedBytes: number; + watcherRunning: boolean; + watchedPath: string | null; + activeOperationCount: number; + activeOperationByCategory: Record; + debuggerSessionCount: number; + simulatorLogSessionCount: number; + deviceLogSessionCount: number; + videoCaptureSessionCount: number; + swiftPackageProcessCount: number; + matchingMcpProcessCount: number | null; + matchingMcpPeerSummary: McpPeerProcessSummary[]; + anomalies: McpLifecycleAnomaly[]; +} + +interface PeerProcessSample { + count: number | null; + peers: McpPeerProcessSummary[]; +} + +interface LifecycleStdinLike { + once(event: string, listener: (...args: unknown[]) => void): this; + removeListener(event: string, listener: (...args: unknown[]) => void): this; +} + +interface LifecycleStdoutLike { + once(event: string, listener: (...args: unknown[]) => void): this; + removeListener(event: string, listener: (...args: unknown[]) => void): this; +} + +interface LifecycleProcessLike { + stdin: LifecycleStdinLike; + stdout?: LifecycleStdoutLike; + once(event: string, listener: (...args: unknown[]) => void): this; + removeListener(event: string, listener: (...args: unknown[]) => void): this; +} + +interface McpLifecycleState { + startedAtMs: number; + phase: McpStartupPhase; + shutdownReason: McpShutdownReason | null; + shutdownPromise: Promise | null; + shutdownRequested: boolean; + server: McpServer | null; +} + +export interface McpLifecycleCoordinator { + attachProcessHandlers(): void; + detachProcessHandlers(): void; + markPhase(phase: McpStartupPhase): void; + registerServer(server: McpServer): void; + isShutdownRequested(): boolean; + getSnapshot(): Promise; + shutdown(reason: McpShutdownReason, error?: unknown): Promise; +} + +export interface McpLifecycleCoordinatorOptions { + commandExecutor?: CommandExecutor; + processRef?: LifecycleProcessLike; + onShutdown: (context: { + reason: McpShutdownReason; + error?: unknown; + snapshot: McpLifecycleSnapshot; + server: McpServer | null; + }) => Promise; +} + +const HIGH_RSS_BYTES = 512 * 1024 * 1024; +const LONG_LIVED_HIGH_RSS_BYTES = 256 * 1024 * 1024; +const LONG_LIVED_UPTIME_MS = 5 * 60 * 1000; +const PEER_AGE_HIGH_SECONDS = 120; +const PEER_COUNT_HIGH_THRESHOLD = 2; + +function parseElapsedSeconds(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const daySplit = trimmed.split('-'); + const timePart = daySplit.length === 2 ? daySplit[1] : daySplit[0]; + const dayCount = daySplit.length === 2 ? Number(daySplit[0]) : 0; + const parts = timePart.split(':').map((part) => Number(part)); + + if (!Number.isFinite(dayCount) || parts.some((part) => !Number.isFinite(part))) { + return null; + } + + if (parts.length === 1) { + return dayCount * 86400 + parts[0]; + } + if (parts.length === 2) { + return dayCount * 86400 + parts[0] * 60 + parts[1]; + } + if (parts.length === 3) { + return dayCount * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + + return null; +} + +export function classifyMcpLifecycleAnomalies( + snapshot: Pick< + McpLifecycleSnapshot, + 'uptimeMs' | 'rssBytes' | 'matchingMcpProcessCount' | 'matchingMcpPeerSummary' + >, +): McpLifecycleAnomaly[] { + const anomalies = new Set(); + const peerCount = Math.max(0, (snapshot.matchingMcpProcessCount ?? 0) - 1); + + if (peerCount >= PEER_COUNT_HIGH_THRESHOLD) { + anomalies.add('peer-count-high'); + } + if (snapshot.matchingMcpPeerSummary.some((peer) => peer.ageSeconds >= PEER_AGE_HIGH_SECONDS)) { + anomalies.add('peer-age-high'); + } + if (snapshot.rssBytes >= HIGH_RSS_BYTES) { + anomalies.add('high-rss'); + } + if (snapshot.uptimeMs >= LONG_LIVED_UPTIME_MS && snapshot.rssBytes >= LONG_LIVED_HIGH_RSS_BYTES) { + anomalies.add('long-lived-high-rss'); + } + + return Array.from(anomalies.values()).sort(); +} + +function isLikelyMcpProcessCommand(command: string): boolean { + const normalized = command.toLowerCase(); + const hasMcpArg = /(^|\s)mcp(\s|$)/.test(normalized); + if (!hasMcpArg) { + return false; + } + + if (/(^|\s)daemon(\s|$)/.test(normalized)) { + return false; + } + + return ( + normalized.includes('xcodebuildmcp') || + normalized.includes('build/cli.js') || + normalized.includes('/cli.js') + ); +} + +function isBrokenPipeLikeError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const code = 'code' in error ? String((error as Error & { code?: unknown }).code ?? '') : ''; + return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED'; +} + +async function sampleMcpPeerProcesses( + commandExecutor: CommandExecutor, + currentPid: number, +): Promise { + try { + const result = await commandExecutor( + ['ps', '-axo', 'pid=,etime=,rss=,command='], + 'Sample MCP lifecycle peer processes', + false, + ); + if (!result.success) { + return { count: null, peers: [] }; + } + + const matched = result.output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const match = line.match(/^(\d+)\s+(\S+)\s+(\d+)\s+(.+)$/); + if (!match) { + return null; + } + const [, pidRaw, elapsedRaw, rssRaw, command] = match; + const ageSeconds = parseElapsedSeconds(elapsedRaw); + return { + pid: Number(pidRaw), + ageSeconds, + rssKb: Number(rssRaw), + command, + }; + }) + .filter( + (entry): entry is { pid: number; ageSeconds: number; rssKb: number; command: string } => { + return ( + entry !== null && + Number.isFinite(entry.pid) && + Number.isFinite(entry.ageSeconds) && + Number.isFinite(entry.rssKb) && + isLikelyMcpProcessCommand(entry.command) + ); + }, + ); + + const peers = matched + .filter((entry) => entry.pid !== currentPid) + .map(({ pid, ageSeconds, rssKb }) => ({ pid, ageSeconds, rssKb })) + .sort((left, right) => { + if (right.ageSeconds !== left.ageSeconds) { + return right.ageSeconds - left.ageSeconds; + } + return right.rssKb - left.rssKb; + }) + .slice(0, 5); + + return { + count: matched.length, + peers, + }; + } catch { + return { count: null, peers: [] }; + } +} + +export async function buildMcpLifecycleSnapshot(options: { + phase: McpStartupPhase; + shutdownReason: McpShutdownReason | null; + startedAtMs: number; + commandExecutor?: CommandExecutor; +}): Promise { + const memoryUsage = process.memoryUsage(); + const activitySnapshot = getDaemonActivitySnapshot(); + const peerSample = await sampleMcpPeerProcesses( + options.commandExecutor ?? getDefaultCommandExecutor(), + process.pid, + ); + + const snapshotWithoutAnomalies = { + pid: process.pid, + phase: options.phase, + shutdownReason: options.shutdownReason, + uptimeMs: Math.max(0, Date.now() - options.startedAtMs), + rssBytes: memoryUsage.rss, + heapUsedBytes: memoryUsage.heapUsed, + watcherRunning: isWatcherRunning(), + watchedPath: getWatchedPath(), + activeOperationCount: activitySnapshot.activeOperationCount, + activeOperationByCategory: activitySnapshot.byCategory, + debuggerSessionCount: getDefaultDebuggerManager().listSessions().length, + simulatorLogSessionCount: activeLogSessions.size, + deviceLogSessionCount: activeDeviceLogSessions.size, + videoCaptureSessionCount: listActiveVideoCaptureSessionIds().length, + swiftPackageProcessCount: activeProcesses.size, + matchingMcpProcessCount: peerSample.count, + matchingMcpPeerSummary: peerSample.peers, + }; + + return { + ...snapshotWithoutAnomalies, + anomalies: classifyMcpLifecycleAnomalies(snapshotWithoutAnomalies), + }; +} + +export function createMcpLifecycleCoordinator( + options: McpLifecycleCoordinatorOptions, +): McpLifecycleCoordinator { + const processRef = options.processRef ?? (process as LifecycleProcessLike); + const state: McpLifecycleState = { + startedAtMs: Date.now(), + phase: 'initializing', + shutdownReason: null, + shutdownPromise: null, + shutdownRequested: false, + server: null, + }; + + const handleSigterm = (): void => { + void coordinator.shutdown('sigterm'); + }; + const handleSigint = (): void => { + void coordinator.shutdown('sigint'); + }; + const handleStdinEnd = (): void => { + void coordinator.shutdown('stdin-end'); + }; + const handleStdinClose = (): void => { + void coordinator.shutdown('stdin-close'); + }; + const handleStdoutError = (error: unknown): void => { + if (!isBrokenPipeLikeError(error)) { + return; + } + void coordinator.shutdown('stdout-error', error); + }; + const handleUncaughtException = (error: unknown): void => { + void coordinator.shutdown('uncaught-exception', error); + }; + const handleUnhandledRejection = (reason: unknown): void => { + void coordinator.shutdown('unhandled-rejection', reason); + }; + + let handlersAttached = false; + + const coordinator: McpLifecycleCoordinator = { + attachProcessHandlers(): void { + if (handlersAttached) { + return; + } + handlersAttached = true; + + processRef.once('SIGTERM', handleSigterm); + processRef.once('SIGINT', handleSigint); + processRef.stdin.once('end', handleStdinEnd); + processRef.stdin.once('close', handleStdinClose); + processRef.stdout?.once('error', handleStdoutError); + processRef.once('uncaughtException', handleUncaughtException); + processRef.once('unhandledRejection', handleUnhandledRejection); + }, + + detachProcessHandlers(): void { + if (!handlersAttached) { + return; + } + handlersAttached = false; + + processRef.removeListener('SIGTERM', handleSigterm); + processRef.removeListener('SIGINT', handleSigint); + processRef.stdin.removeListener('end', handleStdinEnd); + processRef.stdin.removeListener('close', handleStdinClose); + processRef.stdout?.removeListener('error', handleStdoutError); + processRef.removeListener('uncaughtException', handleUncaughtException); + processRef.removeListener('unhandledRejection', handleUnhandledRejection); + }, + + markPhase(phase: McpStartupPhase): void { + state.phase = phase; + }, + + registerServer(server: McpServer): void { + state.server = server; + }, + + isShutdownRequested(): boolean { + return state.shutdownRequested; + }, + + async getSnapshot(): Promise { + return buildMcpLifecycleSnapshot({ + phase: state.phase, + shutdownReason: state.shutdownReason, + startedAtMs: state.startedAtMs, + commandExecutor: options.commandExecutor, + }); + }, + + async shutdown(reason: McpShutdownReason, error?: unknown): Promise { + if (state.shutdownPromise) { + return state.shutdownPromise; + } + + state.shutdownRequested = true; + state.shutdownReason = reason; + const phaseAtShutdown = state.phase; + state.phase = 'shutting-down'; + + state.shutdownPromise = (async (): Promise => { + const snapshot = await buildMcpLifecycleSnapshot({ + phase: phaseAtShutdown, + shutdownReason: state.shutdownReason, + startedAtMs: state.startedAtMs, + commandExecutor: options.commandExecutor, + }); + await options.onShutdown({ + reason, + error, + snapshot, + server: state.server, + }); + state.phase = 'stopped'; + })(); + + return state.shutdownPromise; + }, + }; + + return coordinator; +} diff --git a/src/server/start-mcp-server.ts b/src/server/start-mcp-server.ts index 88ebf67f..a17646b1 100644 --- a/src/server/start-mcp-server.ts +++ b/src/server/start-mcp-server.ts @@ -13,17 +13,54 @@ import { enrichSentryContext, flushAndCloseSentry, initSentry, + recordMcpLifecycleAnomalyMetric, + recordMcpLifecycleMetric, setSentryRuntimeContext, } from '../utils/sentry.ts'; import { getDefaultDebuggerManager } from '../utils/debugger/index.ts'; import { version } from '../version.ts'; import process from 'node:process'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { bootstrapServer } from './bootstrap.ts'; import { shutdownXcodeToolsBridge } from '../integrations/xcode-tools-bridge/index.ts'; import { createStartupProfiler, getStartupProfileNowMs } from './startup-profiler.ts'; import { getConfig } from '../utils/config-store.ts'; import { getRegisteredWorkflows } from '../utils/tool-registry.ts'; import { hydrateSentryDisabledEnvFromProjectConfig } from '../utils/sentry-config.ts'; +import { stopXcodeStateWatcher } from '../utils/xcode-state-watcher.ts'; +import { createMcpLifecycleCoordinator } from './mcp-lifecycle.ts'; + +const FAST_EXIT_SERVER_CLOSE_TIMEOUT_MS = 100; + +interface ClosableMcpServer { + close(): Promise; +} + +type FastExitCloseOutcome = 'skipped' | 'closed' | 'rejected' | 'timed_out'; + +function isTransportDisconnectReason(reason: string): boolean { + return reason === 'stdin-end' || reason === 'stdin-close' || reason === 'stdout-error'; +} + +export async function __closeServerForFastExitForTests( + server: ClosableMcpServer | null | undefined, + timeoutMs = FAST_EXIT_SERVER_CLOSE_TIMEOUT_MS, +): Promise { + if (!server) { + return 'skipped'; + } + + const closePromise = server + .close() + .then(() => 'closed') + .catch(() => 'rejected'); + + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => resolve('timed_out'), timeoutMs); + }); + + return Promise.race([closePromise, timeoutPromise]); +} /** * Start the MCP server. @@ -31,6 +68,117 @@ import { hydrateSentryDisabledEnvFromProjectConfig } from '../utils/sentry-confi * sets up signal handlers for graceful shutdown, and starts the server. */ export async function startMcpServer(): Promise { + const lifecycle = createMcpLifecycleCoordinator({ + onShutdown: async ({ reason, error, snapshot, server }) => { + const closeableServer: Pick | null = server; + const isCrash = reason === 'uncaught-exception' || reason === 'unhandled-rejection'; + const event = isCrash ? 'crash' : 'shutdown'; + const exitCode = + reason === 'stdin-end' || + reason === 'stdin-close' || + reason === 'stdout-error' || + reason === 'sigint' || + reason === 'sigterm' + ? 0 + : 1; + + if (reason === 'stdin-end') { + log('info', 'MCP stdin ended; shutting down MCP server'); + } else if (reason === 'stdin-close') { + log('info', 'MCP stdin closed; shutting down MCP server'); + } else if (reason === 'stdout-error') { + log('info', 'MCP stdout pipe broke; shutting down MCP server'); + } else { + log('info', `MCP shutdown requested: ${reason}`); + } + + log( + 'info', + `[mcp-lifecycle] ${event} ${JSON.stringify(snapshot)}`, + isCrash || snapshot.anomalies.length > 0 ? { sentry: true } : undefined, + ); + + if (isTransportDisconnectReason(reason)) { + lifecycle.detachProcessHandlers(); + await __closeServerForFastExitForTests(closeableServer); + process.exit(exitCode); + } + + recordMcpLifecycleMetric({ + event, + phase: snapshot.phase, + reason, + uptimeMs: snapshot.uptimeMs, + rssBytes: snapshot.rssBytes, + matchingMcpProcessCount: snapshot.matchingMcpProcessCount, + activeOperationCount: snapshot.activeOperationCount, + watcherRunning: snapshot.watcherRunning, + }); + + for (const anomaly of snapshot.anomalies) { + recordMcpLifecycleAnomalyMetric({ + kind: anomaly, + phase: snapshot.phase, + reason, + }); + } + + if (snapshot.anomalies.length > 0) { + log('warn', `[mcp-lifecycle] observed anomalies: ${snapshot.anomalies.join(', ')}`, { + sentry: true, + }); + } + + if (error !== undefined) { + log('error', `MCP shutdown due to ${reason}: ${String(error)}`, { sentry: true }); + } + + if (reason === 'stdin-end' || reason === 'stdin-close') { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + let cleanupExitCode = exitCode; + + try { + await stopXcodeStateWatcher(); + } catch (shutdownError) { + cleanupExitCode = 1; + log('error', `Failed to stop Xcode watcher: ${String(shutdownError)}`, { sentry: true }); + } + + try { + await shutdownXcodeToolsBridge(); + } catch (shutdownError) { + cleanupExitCode = 1; + log('error', `Failed to shutdown Xcode tools bridge: ${String(shutdownError)}`, { + sentry: true, + }); + } + + try { + await getDefaultDebuggerManager().disposeAll(); + } catch (shutdownError) { + cleanupExitCode = 1; + log('error', `Failed to dispose debugger sessions: ${String(shutdownError)}`, { + sentry: true, + }); + } + + try { + await closeableServer?.close(); + } catch (shutdownError) { + cleanupExitCode = 1; + log('error', `Failed to close MCP server: ${String(shutdownError)}`, { sentry: true }); + } + + lifecycle.detachProcessHandlers(); + await flushAndCloseSentry(2000); + process.exit(cleanupExitCode); + }, + }); + + lifecycle.attachProcessHandlers(); + try { const profiler = createStartupProfiler('start-mcp-server'); @@ -38,21 +186,27 @@ export async function startMcpServer(): Promise { // Clients can override via logging/setLevel MCP request setLogLevel('info'); + lifecycle.markPhase('hydrating-sentry-config'); await hydrateSentryDisabledEnvFromProjectConfig(); let stageStartMs = getStartupProfileNowMs(); + lifecycle.markPhase('initializing-sentry'); initSentry({ mode: 'mcp' }); profiler.mark('initSentry', stageStartMs); stageStartMs = getStartupProfileNowMs(); + lifecycle.markPhase('creating-server'); const server = createServer(); + lifecycle.registerServer(server); profiler.mark('createServer', stageStartMs); stageStartMs = getStartupProfileNowMs(); + lifecycle.markPhase('bootstrapping-server'); const bootstrap = await bootstrapServer(server); profiler.mark('bootstrapServer', stageStartMs); stageStartMs = getStartupProfileNowMs(); + lifecycle.markPhase('starting-stdio-transport'); await startServer(server); profiler.mark('startServer', stageStartMs); @@ -69,84 +223,55 @@ export async function startMcpServer(): Promise { xcodeIdeWorkflowEnabled: enabledWorkflows.includes('xcode-ide'), }); - void bootstrap.runDeferredInitialization().catch((error) => { + lifecycle.markPhase('running'); + const startupSnapshot = await lifecycle.getSnapshot(); + log('info', `[mcp-lifecycle] start ${JSON.stringify(startupSnapshot)}`); + recordMcpLifecycleMetric({ + event: 'start', + phase: startupSnapshot.phase, + uptimeMs: startupSnapshot.uptimeMs, + rssBytes: startupSnapshot.rssBytes, + matchingMcpProcessCount: startupSnapshot.matchingMcpProcessCount, + activeOperationCount: startupSnapshot.activeOperationCount, + watcherRunning: startupSnapshot.watcherRunning, + }); + for (const anomaly of startupSnapshot.anomalies) { + recordMcpLifecycleAnomalyMetric({ + kind: anomaly, + phase: startupSnapshot.phase, + }); + } + if (startupSnapshot.anomalies.length > 0) { log( 'warn', - `Deferred bootstrap initialization failed: ${error instanceof Error ? error.message : String(error)}`, + `[mcp-lifecycle] startup anomalies observed: ${startupSnapshot.anomalies.join(', ')}`, + { sentry: true }, ); - }); + } + + lifecycle.markPhase('deferred-initialization'); + void bootstrap + .runDeferredInitialization({ + isShutdownRequested: () => lifecycle.isShutdownRequested(), + }) + .catch((error) => { + log( + 'warn', + `Deferred bootstrap initialization failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }) + .finally(() => { + if (!lifecycle.isShutdownRequested()) { + lifecycle.markPhase('running'); + } + }); setImmediate(() => { enrichSentryContext(); }); - type ShutdownReason = NodeJS.Signals | 'stdin-end' | 'stdin-close'; - - let shuttingDown = false; - const shutdown = async (reason: ShutdownReason): Promise => { - if (shuttingDown) return; - shuttingDown = true; - - if (reason === 'stdin-end') { - log('info', 'MCP stdin ended; shutting down MCP server'); - } else if (reason === 'stdin-close') { - log('info', 'MCP stdin closed; shutting down MCP server'); - } else { - log('info', `Received ${reason}; shutting down MCP server`); - } - - let exitCode = 0; - - if (reason === 'stdin-end' || reason === 'stdin-close') { - // Allow span completion/export to settle after the client closes stdin. - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - try { - await shutdownXcodeToolsBridge(); - } catch (error) { - exitCode = 1; - log('error', `Failed to shutdown Xcode tools bridge: ${String(error)}`, { sentry: true }); - } - - try { - await getDefaultDebuggerManager().disposeAll(); - } catch (error) { - exitCode = 1; - log('error', `Failed to dispose debugger sessions: ${String(error)}`, { sentry: true }); - } - - try { - await server.close(); - } catch (error) { - exitCode = 1; - log('error', `Failed to close MCP server: ${String(error)}`, { sentry: true }); - } - - await flushAndCloseSentry(2000); - process.exit(exitCode); - }; - - process.once('SIGTERM', () => { - void shutdown('SIGTERM'); - }); - - process.once('SIGINT', () => { - void shutdown('SIGINT'); - }); - - process.stdin.once('end', () => { - void shutdown('stdin-end'); - }); - - process.stdin.once('close', () => { - void shutdown('stdin-close'); - }); - log('info', `XcodeBuildMCP server (version ${version}) started successfully`); } catch (error) { - log('error', `Fatal error in startMcpServer(): ${String(error)}`, { sentry: true }); console.error('Fatal error in startMcpServer():', error); - await flushAndCloseSentry(2000); - process.exit(1); + await lifecycle.shutdown('startup-failure', error); } } diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts index d8783675..77bf636e 100644 --- a/src/utils/sentry.ts +++ b/src/utils/sentry.ts @@ -16,6 +16,7 @@ export type SentryToolRuntime = 'cli' | 'daemon' | 'mcp'; export type SentryToolTransport = 'direct' | 'daemon' | 'xcode-ide-daemon'; export type SentryToolInvocationOutcome = 'completed' | 'infra_error'; export type SentryDaemonLifecycleEvent = 'start' | 'shutdown' | 'crash'; +export type SentryMcpLifecycleEvent = 'start' | 'shutdown' | 'crash'; export interface SentryRuntimeContext { mode: SentryRuntimeMode; @@ -380,6 +381,7 @@ interface InternalErrorMetric { } type DaemonGaugeMetricName = 'inflight_requests' | 'active_sessions' | 'idle_timeout_ms'; + function sanitizeTagValue(value: string): string { const trimmed = value.trim().toLowerCase(); if (!trimmed) { @@ -484,3 +486,77 @@ export function recordDaemonGaugeMetric(metricName: DaemonGaugeMetricName, value // Metrics are best effort and must never affect runtime behavior. } } + +interface McpLifecycleMetric { + event: SentryMcpLifecycleEvent; + phase: string; + reason?: string; + uptimeMs: number; + rssBytes: number; + matchingMcpProcessCount?: number | null; + activeOperationCount: number; + watcherRunning: boolean; +} + +interface McpLifecycleAnomalyMetric { + kind: string; + phase: string; + reason?: string; +} + +export function recordMcpLifecycleMetric(metric: McpLifecycleMetric): void { + if (!shouldEmitMetrics()) { + return; + } + + const attributes = { + runtime: 'mcp', + event: sanitizeTagValue(metric.event), + phase: sanitizeTagValue(metric.phase), + ...(metric.reason ? { reason: sanitizeTagValue(metric.reason) } : {}), + watcher_running: String(metric.watcherRunning), + has_active_operations: String(metric.activeOperationCount > 0), + }; + + try { + Sentry.metrics.count('xcodebuildmcp.mcp.lifecycle.count', 1, { attributes }); + Sentry.metrics.distribution( + 'xcodebuildmcp.mcp.lifecycle.uptime_ms', + Math.max(0, metric.uptimeMs), + { attributes }, + ); + Sentry.metrics.distribution( + 'xcodebuildmcp.mcp.lifecycle.rss_bytes', + Math.max(0, metric.rssBytes), + { attributes }, + ); + if (metric.matchingMcpProcessCount != null) { + Sentry.metrics.distribution( + 'xcodebuildmcp.mcp.lifecycle.process_count', + Math.max(0, metric.matchingMcpProcessCount), + { attributes }, + ); + } + } catch { + // Metrics are best effort and must never affect runtime behavior. + } +} + +export function recordMcpLifecycleAnomalyMetric(metric: McpLifecycleAnomalyMetric): void { + if (!shouldEmitMetrics()) { + return; + } + + try { + Sentry.metrics.count('xcodebuildmcp.mcp.lifecycle.anomaly.count', 1, { + attributes: { + runtime: 'mcp', + kind: sanitizeTagValue(metric.kind), + phase: sanitizeTagValue(metric.phase), + ...(metric.reason ? { reason: sanitizeTagValue(metric.reason) } : {}), + }, + }); + } catch { + // Metrics are best effort and must never affect runtime behavior. + } +} diff --git a/src/utils/session-status.ts b/src/utils/session-status.ts index 55bd2ab4..d2fa4d9c 100644 --- a/src/utils/session-status.ts +++ b/src/utils/session-status.ts @@ -1,6 +1,10 @@ import { getDefaultDebuggerManager } from './debugger/index.ts'; import { listActiveSimulatorLogSessionIds } from './log-capture/index.ts'; import { activeDeviceLogSessions } from './log-capture/device-log-sessions.ts'; +import { getDaemonActivitySnapshot } from '../daemon/activity-registry.ts'; +import { activeProcesses } from '../mcp/tools/swift-package/active-processes.ts'; +import { listActiveVideoCaptureSessionIds } from './video_capture.ts'; +import { getWatchedPath, isWatcherRunning } from './xcode-state-watcher.ts'; export type SessionRuntimeStatusSnapshot = { logging: { @@ -11,14 +15,36 @@ export type SessionRuntimeStatusSnapshot = { currentSessionId: string | null; sessionIds: string[]; }; + watcher: { + running: boolean; + watchedPath: string | null; + }; + video: { + activeSessionIds: string[]; + }; + swiftPackage: { + activePids: number[]; + }; + activity: { + activeOperationCount: number; + byCategory: Record; + }; + process: { + pid: number; + uptimeMs: number; + rssBytes: number; + heapUsedBytes: number; + }; }; export function getSessionRuntimeStatusSnapshot(): SessionRuntimeStatusSnapshot { const debuggerManager = getDefaultDebuggerManager(); + const activitySnapshot = getDaemonActivitySnapshot(); const sessionIds = debuggerManager .listSessions() .map((session) => session.id) .sort(); + const memoryUsage = process.memoryUsage(); return { logging: { @@ -33,5 +59,22 @@ export function getSessionRuntimeStatusSnapshot(): SessionRuntimeStatusSnapshot currentSessionId: debuggerManager.getCurrentSessionId(), sessionIds, }, + watcher: { + running: isWatcherRunning(), + watchedPath: getWatchedPath(), + }, + video: { + activeSessionIds: listActiveVideoCaptureSessionIds(), + }, + swiftPackage: { + activePids: Array.from(activeProcesses.keys()).sort((left, right) => left - right), + }, + activity: activitySnapshot, + process: { + pid: process.pid, + uptimeMs: Math.max(0, Math.round(process.uptime() * 1000)), + rssBytes: memoryUsage.rss, + heapUsedBytes: memoryUsage.heapUsed, + }, }; }