pi-mcp-extension 1.11.2 → 1.11.3
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/README.md +75 -46
- package/package.json +1 -1
- package/src/server-manager.ts +25 -2
- package/src/stdio-process.ts +296 -23
package/README.md
CHANGED
|
@@ -20,14 +20,15 @@
|
|
|
20
20
|
- **Clean cancellation** — `AbortSignal` propagation via `notifications/cancelled`
|
|
21
21
|
- **Smart reconnection** — Fixed delay retry schedule (1s → 3s → 5s → 10s → 30s), configurable max retries, and automatic recovery when a server transport closes unexpectedly
|
|
22
22
|
- **Health checks** — Opt-in periodic pings to detect stale connections
|
|
23
|
-
- **Safe process lifecycle** — Child PID tracking (`StdioClientTransport.pid`) with
|
|
23
|
+
- **Safe process lifecycle** — Child PID tracking (`StdioClientTransport.pid`) with tree-aware cleanup: descendants of wrapper commands (`npx`, `npm exec`, `uvx`, `sh -c`) are terminated too, and inherited pipes are released so teardown never stalls
|
|
24
24
|
- **Stable tool names** — Sanitized, collision-avoiding names (max 64 chars) that stay constant across reconnects
|
|
25
25
|
- **No tool churn** — Activate/deactivate pattern ensures tools don't flicker during reconnection
|
|
26
|
-
- **Layered config & trust boundary** — Global `<agent dir>/mcp.json`, project `.pi/mcp.json` (loaded only in trusted projects via `ctx.isProjectTrusted()
|
|
26
|
+
- **Layered config & trust boundary** — Global `<agent dir>/mcp.json`, project `.pi/mcp.json` (loaded only in trusted projects via `ctx.isProjectTrusted()`, including Pi 1.3.0 partial overrides), and Pi 1.x extension-registered servers (`pi.registerMcpServer()`)
|
|
27
27
|
- **Rich tool results & annotations** — MCP `image` content passthrough (`ImageContent`), plus `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` surfaced in tool descriptions
|
|
28
|
-
- **On-demand agent tools & commands** — `mcp_status` and `mcp_connect` agent tools plus `/mcp`, `/mcp:start`, `/mcp:stop`, and `/mcp:auth` slash commands with server-name autocomplete
|
|
29
|
-
- **Structured error handling** — Distinct error codes
|
|
30
|
-
- **
|
|
28
|
+
- **On-demand agent tools & commands** — `mcp_status` and `mcp_connect` agent tools plus `/mcp`, `/mcp <name>` (with config provenance and failure hints), `/mcp:start`, `/mcp:stop`, and `/mcp:auth` slash commands with server-name autocomplete
|
|
29
|
+
- **Structured error handling** — Distinct error codes (`config`, `connection`, `auth`, `timeout`, `protocol`, `tool`) and actionable failure hints
|
|
30
|
+
- **Redacted debug logging** — Opt-in `PI_MCP_DEBUG=1` JSON Lines log (`<agent dir>/logs/pi-mcp.log`, `0600`, ~2 MB rotation) with automatic secret masking and zero TUI `stderr` noise
|
|
31
|
+
- **OAuth 2.1** — Browser and headless/SSH authorization with PKCE, protected-resource discovery, Dynamic Client Registration, startup prompts for eager servers, cross-process lock recovery, token refresh, and origin/issuer-bound local persistence (see [OAUTH.md](./OAUTH.md))
|
|
31
32
|
|
|
32
33
|
## Installation
|
|
33
34
|
|
|
@@ -57,7 +58,7 @@ pi -e npm:pi-mcp-extension
|
|
|
57
58
|
}
|
|
58
59
|
```
|
|
59
60
|
|
|
60
|
-
3. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level):
|
|
61
|
+
3. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level). Both files support **JSONC** (`//` line and `/* ... */` block comments):
|
|
61
62
|
```jsonc
|
|
62
63
|
{
|
|
63
64
|
"mcpServers": {
|
|
@@ -70,11 +71,11 @@ pi -e npm:pi-mcp-extension
|
|
|
70
71
|
}
|
|
71
72
|
```
|
|
72
73
|
|
|
73
|
-
4. Start Pi — your MCP tools are ready. Use `/mcp` to check
|
|
74
|
+
4. Start Pi — your MCP tools are ready. Use `/mcp` to check all servers or `/mcp <name>` to inspect one in detail.
|
|
74
75
|
|
|
75
76
|
## Configuration
|
|
76
77
|
|
|
77
|
-
Servers can be configured via JSON files or registered programmatically by other Pi 1.x extensions:
|
|
78
|
+
Servers can be configured via JSON/JSONC files (`//` and `/* */` comments are supported) or registered programmatically by other Pi 1.x extensions:
|
|
78
79
|
|
|
79
80
|
| Source | Precedence & Scope |
|
|
80
81
|
|---|---|
|
|
@@ -103,7 +104,7 @@ Servers can be configured via JSON files or registered programmatically by other
|
|
|
103
104
|
"lifecycle": "eager"
|
|
104
105
|
},
|
|
105
106
|
"deepsource": {
|
|
106
|
-
"url": "https://mcp.deepsource.
|
|
107
|
+
"url": "https://mcp.deepsource.com/mcp", // "transport" defaults to "streamable-http" when "url" is set
|
|
107
108
|
"lifecycle": "eager"
|
|
108
109
|
},
|
|
109
110
|
|
|
@@ -123,6 +124,7 @@ Servers can be configured via JSON files or registered programmatically by other
|
|
|
123
124
|
"context7": {
|
|
124
125
|
"command": "npx",
|
|
125
126
|
"args": ["-y", "@context7/mcp"],
|
|
127
|
+
"cwd": "./",
|
|
126
128
|
"transport": "stdio",
|
|
127
129
|
"lifecycle": "lazy"
|
|
128
130
|
},
|
|
@@ -184,14 +186,14 @@ An override may set `enabled`, `lifecycle`, and Pi's `exposure` / `toolExposure`
|
|
|
184
186
|
|
|
185
187
|
### OAuth Config
|
|
186
188
|
|
|
187
|
-
Configured under `auth` (or `oauth`). Token and client state are persisted per server in `<agent dir>/mcp-auth/<hash>.json` (`0700` directory, `0600` files on POSIX) with atomic writes and automatic backup of corrupt state files. See [OAUTH.md](./OAUTH.md) for architecture and troubleshooting details.
|
|
189
|
+
Configured under `auth` (or `oauth`). Token and client state are persisted per server in `<agent dir>/mcp-auth/<hash>.json` (`0700` directory, `0600` files on POSIX) with atomic writes, origin (`serverUrl`) and authorization-server (`issuer`) binding, and automatic backup of corrupt state files. See [OAUTH.md](./OAUTH.md) for architecture and troubleshooting details.
|
|
188
190
|
|
|
189
191
|
| Field | Type | Default | Description |
|
|
190
192
|
|---|---|---|---|
|
|
191
193
|
| `type` | `"oauth"` | `"oauth"` | Authentication type |
|
|
192
194
|
| `scope` | `string` | discovered | Requested scope when the server does not advertise one in `WWW-Authenticate` |
|
|
193
195
|
| `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID (skips Dynamic Client Registration) |
|
|
194
|
-
| `clientSecret` | `string` | — | Secret for a pre-registered confidential client |
|
|
196
|
+
| `clientSecret` | `string` | — | Secret for a pre-registered confidential client (used in memory; never written to disk) |
|
|
195
197
|
| `clientName` (or `oauth.clientName`) | `string` | `pi-mcp/<server name>` | `client_name` sent with Dynamic Client Registration, for servers that only accept known clients |
|
|
196
198
|
| `redirectUrl` (or `oauth.callbackUrl` / `oauth.callbackPort`) | `string` | `http://127.0.0.1:19876/callback` | Local HTTP loopback callback (`localhost`, `127.0.0.1`, or `::1` with path `/callback` and an explicit non-zero port; `callbackPort` maps to `http://127.0.0.1:<port>/callback`). Scans forward from `19876` when omitted; binds the exact host and port when configured. |
|
|
197
199
|
|
|
@@ -199,7 +201,7 @@ Configured under `auth` (or `oauth`). Token and client state are persisted per s
|
|
|
199
201
|
|
|
200
202
|
Server-name arguments provide autocomplete suggestions from the configured MCP servers. `/mcp:auth` suggests only servers configured for OAuth.
|
|
201
203
|
|
|
202
|
-
| Command | Description |
|
|
204
|
+
| Slash Command | Description |
|
|
203
205
|
|---|---|
|
|
204
206
|
| `/mcp` | Show status summary of all configured servers |
|
|
205
207
|
| `/mcp <name>` | Show detailed status for one server: config source, transport/lifecycle, tool activation, OAuth credential status, failure hint, and stderr log |
|
|
@@ -208,7 +210,12 @@ Server-name arguments provide autocomplete suggestions from the configured MCP s
|
|
|
208
210
|
| `/mcp:auth <name>` | Connect with stored OAuth credentials and client registration; request browser authorization only if needed |
|
|
209
211
|
| `/mcp:auth <name> --reset` | Discard stored tokens and client registration (re-running Dynamic Client Registration) and request fresh authorization |
|
|
210
212
|
|
|
211
|
-
|
|
213
|
+
In addition to user slash commands, two built-in **agent tools** let the LLM inspect and connect configured servers directly:
|
|
214
|
+
|
|
215
|
+
| Agent Tool | Parameters | Description |
|
|
216
|
+
|---|---|---|
|
|
217
|
+
| `mcp_status` | `{}` | List configured servers with `lifecycle`, `state`, `tools` (`discovered` / `active`), `auth` credential presence, `source` (`global` / `project` / `extension`), and safe `error` action hints (never returns tokens, headers, or authorization URLs) |
|
|
218
|
+
| `mcp_connect` | `{ name: string }` | Connect one named server, run interactive OAuth if needed, and verify that discovered tools are active before returning success (returns fixed actionable categories on failure instead of raw server response text) |
|
|
212
219
|
|
|
213
220
|
### Inspecting one server (`/mcp <name>`)
|
|
214
221
|
|
|
@@ -276,39 +283,53 @@ While a Pi UI is active, the extension never writes diagnostics to Pi's stderr (
|
|
|
276
283
|
|
|
277
284
|
## How It Works
|
|
278
285
|
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
│ Pi Agent
|
|
282
|
-
│
|
|
283
|
-
│
|
|
284
|
-
│ │ index.ts
|
|
285
|
-
│ │ (entry
|
|
286
|
-
│
|
|
287
|
-
│
|
|
288
|
-
│
|
|
289
|
-
│
|
|
290
|
-
│
|
|
291
|
-
│
|
|
292
|
-
│
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
286
|
+
```text
|
|
287
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
288
|
+
│ Pi Agent (TUI / RPC, ExtensionAPI) │
|
|
289
|
+
│ │
|
|
290
|
+
│ ┌────────────────────┐ ┌────────────────────────┐ │
|
|
291
|
+
│ │ index.ts │───▶│ register-commands │ (/mcp, :start, │
|
|
292
|
+
│ │ (entry & mcp_* │ │ (/mcp* slash commands) │ :stop, :auth) │
|
|
293
|
+
│ │ agent tools) │ └───────────┬────────────┘ │
|
|
294
|
+
│ └────┬──────────┬────┘ │ │
|
|
295
|
+
│ │ └──────────────┐ │ │
|
|
296
|
+
│ │ ▼ ▼ │
|
|
297
|
+
│ │ ┌────────────────────────┐ │
|
|
298
|
+
│ │ │ connection-coordinator │ │
|
|
299
|
+
│ │ │ (in-flight connects, │ │
|
|
300
|
+
│ │ │ authQueue, teardown) │ │
|
|
301
|
+
│ │ └───────────┬────────────┘ │
|
|
302
|
+
│ │ │ │
|
|
303
|
+
│ │ ▼ │
|
|
304
|
+
│ │ ┌────────────────────────┐ ┌───────────┐ │
|
|
305
|
+
│ │ │ server-manager │───▶│tool-bridge│ │
|
|
306
|
+
│ │ │ (stopped → starting │ │(MCP → Pi) │ │
|
|
307
|
+
│ │ │ → ready lifecycle) │ └─────┬─────┘ │
|
|
308
|
+
│ ▼ └───────────┬────────────┘ │ │
|
|
309
|
+
│ ┌────────────────────┐ ▼ ▼ │
|
|
310
|
+
│ │ config │ ┌────────────────────────┐ ┌───────────┐ │
|
|
311
|
+
│ │ (Zod + JSONC) │ │ transport-factory & │ │ TypeBox │ │
|
|
312
|
+
│ └────────┬───────────┘ │ oauth-provider (SDK) │ │ (schema) │ │
|
|
313
|
+
│ │ └───────────┬────────────┘ └───────────┘ │
|
|
314
|
+
└───────────┼────────────────────────────┼────────────────────────────────┘
|
|
315
|
+
│ │
|
|
316
|
+
▼ ▼
|
|
317
|
+
Global/Project mcp.json ┌──────────────┐
|
|
318
|
+
+ Registered Extensions │ MCP Servers │
|
|
319
|
+
│ (stdio/http) │
|
|
320
|
+
└──────────────┘
|
|
300
321
|
```
|
|
301
322
|
|
|
302
323
|
1. **Config is loaded** from global `<agent dir>/mcp.json`, trusted project `.pi/mcp.json` (checked via `ctx.isProjectTrusted()` on `session_start`), and extension-registered servers (`pi.getMcpServers()` / `mcp_servers_change`), with `mcp.json` taking precedence over extension-registered servers and project entries overriding global ones
|
|
303
|
-
2. **Eager servers connect** at `session_start` (offering a sequential `Authorize now` / `Not now` prompt when an eager OAuth server needs authorization in an interactive session); lazy servers wait for `mcp_connect`, `/mcp:start`, or `/mcp:auth`
|
|
324
|
+
2. **Eager servers connect** at `session_start` via `ConnectionCoordinator` (offering a sequential `Authorize now` / `Not now` prompt when an eager OAuth server needs authorization in an interactive session); lazy servers wait for `mcp_connect`, `/mcp:start`, or `/mcp:auth`
|
|
304
325
|
3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages) before a server enters `ready`
|
|
305
|
-
4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas
|
|
326
|
+
4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas (preserving nullable union ordering, resolving `$ref`s cycle-safely, and inferring/flattening `allOf` object schemas)
|
|
306
327
|
5. **Pi tools are registered** as `<prefix>_<server>_<tool>` (sanitized, max 64 chars with hash suffix) and pass through text and `image` content blocks (`ImageContent`)
|
|
307
328
|
6. **Tools activate/deactivate** automatically as servers connect/disconnect
|
|
308
329
|
7. **AbortSignal** is wired through to `notifications/cancelled` for clean cancellation
|
|
309
330
|
8. **Reconnection & recovery** uses a fixed delay schedule with configurable max retries on initial failure, health check failure, or unexpected transport close (`client.onclose`)
|
|
310
331
|
9. **`list_changed` notifications** trigger re-discovery, deactivating stale tools automatically
|
|
311
|
-
10. **Session shutdown** cancels pending auth/connection flows, stops the callback server, deactivates all tools, and cleanly stops all servers (with a `SIGKILL` safety net for `stdio` child processes)
|
|
332
|
+
10. **Session shutdown** cancels pending auth/connection flows, stops the callback server, deactivates all tools, flushes the debug log, and cleanly stops all servers (with a `SIGKILL` safety net for `stdio` child processes)
|
|
312
333
|
|
|
313
334
|
## MCP Spec Compliance
|
|
314
335
|
|
|
@@ -340,30 +361,38 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, workflow, and the pre-merge
|
|
|
340
361
|
## Development
|
|
341
362
|
|
|
342
363
|
```bash
|
|
343
|
-
# Install dependencies
|
|
344
|
-
npm
|
|
364
|
+
# Install dependencies (clean lockfile install)
|
|
365
|
+
npm ci
|
|
345
366
|
|
|
346
|
-
# Lint (type-aware ESLint)
|
|
367
|
+
# Lint (type-aware ESLint + structural/complexity checks)
|
|
347
368
|
npm run lint
|
|
348
369
|
|
|
349
370
|
# Type check (strict mode)
|
|
350
371
|
npm run typecheck
|
|
351
372
|
|
|
352
|
-
# Run all
|
|
373
|
+
# Run all test suites
|
|
353
374
|
npm test
|
|
354
375
|
|
|
376
|
+
# Run all test suites with coverage floor (95% lines / 85% branches / 83% functions)
|
|
377
|
+
npm run test:coverage
|
|
378
|
+
|
|
355
379
|
# Run integration tests only (real stdio server)
|
|
356
380
|
npm run test:integration
|
|
381
|
+
|
|
382
|
+
# Verify npm tarball contents
|
|
383
|
+
npm pack --dry-run
|
|
357
384
|
```
|
|
358
385
|
|
|
359
386
|
### Testing
|
|
360
387
|
|
|
361
388
|
The test suite includes:
|
|
362
|
-
- **Config & Pi 1.x compatibility** — Zod schema, `$PI_CODING_AGENT_DIR` resolution, global/project merge, project trust boundary (`ctx.isProjectTrusted()`), Pi 1.x schema aliases (`type: "http"`, URL-only HTTP, `oauth`, `timeout`), and dynamic `pi.getMcpServers()` / `mcp_servers_change`
|
|
363
|
-
- **Server lifecycle & coordination** — Connect, retry, unexpected close recovery, shutdown with PID `SIGKILL` safety net, health check, concurrent connect/reset coordination, and cancellation
|
|
364
|
-
- **OAuth 2.1 & auth locks** — Challenge discovery, PKCE, Dynamic Client Registration, startup authorization prompts, headless/SSH redirect URL paste-back, URL scheme validation, cross-process lock heartbeat and stale lock reclamation
|
|
365
|
-
- **Tool bridge &
|
|
366
|
-
- **
|
|
389
|
+
- **Config & Pi 1.x / 1.3.0 compatibility** (`tests/config.test.ts`, `tests/pi-1x-compat.test.ts`) — Zod schema, JSONC comments, `$PI_CODING_AGENT_DIR` resolution, global/project merge, project trust boundary (`ctx.isProjectTrusted()`), Pi 1.3.0 `enabled: false` and partial project overrides, `stdio` `cwd`, `oauth.clientName`, Pi 1.x schema aliases (`type: "http"`, URL-only HTTP, `oauth`, `timeout`), and dynamic `pi.getMcpServers()` / `mcp_servers_change`
|
|
390
|
+
- **Server lifecycle & coordination** (`tests/server-manager.test.ts`, `tests/stdio-process.test.ts`, `tests/transport-factory.test.ts`, `tests/connection-coordinator.test.ts`, `tests/connection-coordination.test.ts`) — Connect, retry, unexpected close recovery, shutdown with PID `SIGKILL` safety net, health check, concurrent connect/reset coordination, and cancellation
|
|
391
|
+
- **OAuth 2.1 & auth locks** (`tests/oauth-fixes.test.ts`, `tests/authorize-flow.test.ts`, `tests/auth-lock.test.ts`, `tests/auth-commands.test.ts`, `tests/auth-arguments.test.ts`, `tests/fake-oauth-server.test.ts`) — Challenge discovery, PKCE, Dynamic Client Registration, origin and issuer binding, startup authorization prompts, headless/SSH redirect URL paste-back, URL scheme validation, cross-process lock heartbeat, and stale lock reclamation
|
|
392
|
+
- **Tool bridge, status diagnostics & debug log** (`tests/tool-bridge.test.ts`, `tests/management-tools.test.ts`, `tests/server-status.test.ts`, `tests/status-commands.test.ts`, `tests/completions.test.ts`, `tests/debug-log.test.ts`) — JSON Schema → TypeBox conversion (`allOf` object inference, `$ref` cycle protection, union `null` ordering), name sanitization, image content passthrough, `isError` filtering, activate/deactivate, `mcp_status` / `mcp_connect`, `/mcp <name>` config provenance and failure hints, slash command completions, and redacted `PI_MCP_DEBUG=1` JSONL logging
|
|
393
|
+
- **Property-based fuzzing** (`tests/fuzz.test.ts`) — `fast-check` properties covering JSONC comment stripping, callback/redirect URL validation, `WWW-Authenticate` challenge parsing, JSON Schema → TypeBox conversion, tool naming, terminal/log secret sanitization, and custom header merging
|
|
394
|
+
- **Release flow & repository policy** (`tests/release.test.ts`, `tests/ruleset-audit.test.ts`, `tests/repo-policy.test.ts`) — Release PR orchestration and npm/git self-healing (`scripts/release.ts`), live GitHub ruleset drift auditing (`scripts/ruleset-audit.ts`), and CI/ESLint/Qlty policy invariants
|
|
395
|
+
- **End-to-end integration** — Real mock MCP and OAuth servers over `stdio` and HTTP with tool discovery and execution
|
|
367
396
|
|
|
368
397
|
## Security
|
|
369
398
|
|
package/package.json
CHANGED
package/src/server-manager.ts
CHANGED
|
@@ -29,8 +29,10 @@ import type { ToolRefreshOptions } from "./tool-bridge.js";
|
|
|
29
29
|
import { sanitizeTerminalText, type SanitizeTerminalTextOptions } from "./terminal-text.js";
|
|
30
30
|
import { describeError, reportDiagnostic } from "./debug-log.js";
|
|
31
31
|
import {
|
|
32
|
+
destroyStdioStreams,
|
|
32
33
|
ensurePidTerminated,
|
|
33
34
|
instrumentStdioTransport,
|
|
35
|
+
waitForChildClose,
|
|
34
36
|
} from "./stdio-process.js";
|
|
35
37
|
import { createTransport } from "./transport-factory.js";
|
|
36
38
|
import {
|
|
@@ -827,6 +829,26 @@ export class ServerManager {
|
|
|
827
829
|
};
|
|
828
830
|
}
|
|
829
831
|
|
|
832
|
+
/**
|
|
833
|
+
* Terminate the stdio child process tree, then release the child's pipe handles.
|
|
834
|
+
*
|
|
835
|
+
* WHY: `client.close()` waits for the child's `'close'` event, which never fires while a
|
|
836
|
+
* leaked grandchild still holds the inherited stdout/stderr write ends — the SDK then
|
|
837
|
+
* burns its full 2 × 2,000ms escalation budget. Killing the tree first and destroying the
|
|
838
|
+
* pipes afterwards keeps teardown bounded even if a descendant escaped the tree kill.
|
|
839
|
+
*/
|
|
840
|
+
private async _terminateStdioTree(
|
|
841
|
+
pid: number | null,
|
|
842
|
+
transport: Transport | undefined,
|
|
843
|
+
): Promise<void> {
|
|
844
|
+
try {
|
|
845
|
+
await ensurePidTerminated(pid);
|
|
846
|
+
await waitForChildClose(transport);
|
|
847
|
+
} finally {
|
|
848
|
+
destroyStdioStreams(transport);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
830
852
|
private async _cleanupFailedConnect(
|
|
831
853
|
server: ManagedServer,
|
|
832
854
|
client: Client,
|
|
@@ -839,7 +861,7 @@ export class ServerManager {
|
|
|
839
861
|
server.childPid = null;
|
|
840
862
|
await Promise.allSettled([
|
|
841
863
|
client.close(),
|
|
842
|
-
|
|
864
|
+
this._terminateStdioTree(pid, transport),
|
|
843
865
|
]);
|
|
844
866
|
// WHY: Secondary safety check to guarantee PID termination even if transport.close()
|
|
845
867
|
// or child process exit raced during the initial Promise.allSettled.
|
|
@@ -1081,13 +1103,14 @@ export class ServerManager {
|
|
|
1081
1103
|
server.childPid ??
|
|
1082
1104
|
(server.transport as unknown as { _process?: { pid?: number } })?._process?.pid ??
|
|
1083
1105
|
null;
|
|
1106
|
+
const transport = server.transport;
|
|
1084
1107
|
server.client = null;
|
|
1085
1108
|
server.transport = undefined;
|
|
1086
1109
|
server.childPid = null;
|
|
1087
1110
|
|
|
1088
1111
|
await Promise.allSettled([
|
|
1089
1112
|
client?.close(),
|
|
1090
|
-
|
|
1113
|
+
this._terminateStdioTree(pid, transport),
|
|
1091
1114
|
]);
|
|
1092
1115
|
await ensurePidTerminated(pid);
|
|
1093
1116
|
}
|
package/src/stdio-process.ts
CHANGED
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides pure StringDecoder-based line splitting across chunk boundaries,
|
|
5
5
|
* StdioClientTransport instrumentation (early PID capture, memoized close,
|
|
6
|
-
* stderr wiring),
|
|
7
|
-
*
|
|
6
|
+
* stderr wiring), descendant process-tree discovery for wrappers such as
|
|
7
|
+
* `npx`, `uvx`, or `sh -c`, and deterministic SIGTERM → bounded wait → SIGKILL
|
|
8
|
+
* cleanup with injectable process/timer dependencies.
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
10
13
|
import { StringDecoder } from "node:string_decoder";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import type { ChildProcess } from "node:child_process";
|
|
11
16
|
import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
12
17
|
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
|
|
13
20
|
// ─── Line Splitting ───────────────────────────────────────────────────────────
|
|
14
21
|
|
|
15
22
|
export interface LineSplitter {
|
|
@@ -27,6 +34,7 @@ export function createLineSplitter(onLine: (line: string) => void): LineSplitter
|
|
|
27
34
|
|
|
28
35
|
return {
|
|
29
36
|
write(chunk: Buffer): void {
|
|
37
|
+
flushed = false;
|
|
30
38
|
remainder += decoder.write(chunk);
|
|
31
39
|
const lines = remainder.split("\n");
|
|
32
40
|
remainder = lines.pop() ?? "";
|
|
@@ -49,6 +57,25 @@ export function createLineSplitter(onLine: (line: string) => void): LineSplitter
|
|
|
49
57
|
|
|
50
58
|
// ─── Transport Instrumentation ────────────────────────────────────────────────
|
|
51
59
|
|
|
60
|
+
/** Grace window for an exited child's pipes to drain naturally before force-destroying them. */
|
|
61
|
+
const PIPE_DRAIN_GRACE_MS = 100;
|
|
62
|
+
|
|
63
|
+
interface StdioChildRecord {
|
|
64
|
+
process: ChildProcess;
|
|
65
|
+
closed: boolean;
|
|
66
|
+
/** Flush the buffered partial stderr line before the pipe is destroyed. */
|
|
67
|
+
flushStderr(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Child processes of instrumented transports, keyed by transport.
|
|
72
|
+
*
|
|
73
|
+
* WHY: `StdioClientTransport.close()` clears `_process` synchronously, so the child
|
|
74
|
+
* handle is gone by the time teardown needs its pipes and its stderr flush. A WeakMap
|
|
75
|
+
* keeps both reachable without pinning a closed transport in memory.
|
|
76
|
+
*/
|
|
77
|
+
const childRecords = new WeakMap<object, StdioChildRecord>();
|
|
78
|
+
|
|
52
79
|
export interface InstrumentStdioTransportOptions {
|
|
53
80
|
onPid(pid: number): void;
|
|
54
81
|
onStderrLine(line: string): void;
|
|
@@ -66,17 +93,35 @@ export function instrumentStdioTransport(
|
|
|
66
93
|
// WHY: Capture child PID immediately when StdioClientTransport spawns the process
|
|
67
94
|
// (before `initialize` is sent), so a timeout or cancellation during `initialize`
|
|
68
95
|
// can still terminate the child process deterministically before retrying.
|
|
96
|
+
const spawnedProcess = (): ChildProcess | undefined =>
|
|
97
|
+
(transport as unknown as { _process?: ChildProcess })._process;
|
|
98
|
+
|
|
99
|
+
let flushStderr: (() => void) | null = null;
|
|
100
|
+
|
|
101
|
+
const recordProcess = (proc: ChildProcess | undefined): void => {
|
|
102
|
+
if (!proc?.pid) return;
|
|
103
|
+
if (!childRecords.has(transport)) {
|
|
104
|
+
const record: StdioChildRecord = {
|
|
105
|
+
process: proc,
|
|
106
|
+
closed: false,
|
|
107
|
+
flushStderr: () => flushStderr?.(),
|
|
108
|
+
};
|
|
109
|
+
proc.once?.("close", () => {
|
|
110
|
+
record.closed = true;
|
|
111
|
+
});
|
|
112
|
+
childRecords.set(transport, record);
|
|
113
|
+
}
|
|
114
|
+
onPid(proc.pid);
|
|
115
|
+
};
|
|
116
|
+
|
|
69
117
|
const origStart = transport.start.bind(transport);
|
|
70
118
|
transport.start = async () => {
|
|
71
119
|
const startPromise = origStart();
|
|
72
|
-
|
|
73
|
-
if (earlyProc?.pid) {
|
|
74
|
-
onPid(earlyProc.pid);
|
|
75
|
-
}
|
|
120
|
+
recordProcess(spawnedProcess());
|
|
76
121
|
await startPromise;
|
|
77
|
-
const proc = (
|
|
122
|
+
const proc = spawnedProcess();
|
|
78
123
|
if (proc?.pid) {
|
|
79
|
-
|
|
124
|
+
recordProcess(proc);
|
|
80
125
|
} else if (transport.pid) {
|
|
81
126
|
onPid(transport.pid);
|
|
82
127
|
}
|
|
@@ -99,26 +144,212 @@ export function instrumentStdioTransport(
|
|
|
99
144
|
transport.stderr.on("data", (chunk: Buffer) => {
|
|
100
145
|
splitter.write(chunk);
|
|
101
146
|
});
|
|
102
|
-
const
|
|
147
|
+
const flush = (): void => {
|
|
103
148
|
splitter.end();
|
|
104
149
|
};
|
|
105
|
-
|
|
106
|
-
transport.stderr.on("
|
|
150
|
+
flushStderr = flush;
|
|
151
|
+
transport.stderr.on("end", flush);
|
|
152
|
+
transport.stderr.on("close", flush);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wait briefly for an exited child process to drain its stdio pipes and emit `'close'`
|
|
158
|
+
* naturally before `destroyStdioStreams` force-destroys the parent's pipe read ends.
|
|
159
|
+
*
|
|
160
|
+
* WHY: On Windows, `process.stderr` is an overlapped named pipe whose final bytes can
|
|
161
|
+
* still be in flight in libuv when `TerminateProcess` completes. Letting `'close'` fire
|
|
162
|
+
* naturally preserves trailing stderr output when no grandchild holds the pipe open, while
|
|
163
|
+
* bounding the wait to `PIPE_DRAIN_GRACE_MS` when a surviving grandchild keeps it open.
|
|
164
|
+
*/
|
|
165
|
+
export async function waitForChildClose(
|
|
166
|
+
transport: object | null | undefined,
|
|
167
|
+
timeoutMs = PIPE_DRAIN_GRACE_MS,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
if (!transport) return;
|
|
170
|
+
const record = childRecords.get(transport);
|
|
171
|
+
if (!record || record.closed || typeof record.process.once !== "function") return;
|
|
172
|
+
await new Promise<void>((resolve) => {
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
record.process.removeListener?.("close", onClose);
|
|
175
|
+
resolve();
|
|
176
|
+
}, timeoutMs);
|
|
177
|
+
const onClose = (): void => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
resolve();
|
|
180
|
+
};
|
|
181
|
+
record.process.once("close", onClose);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Destroy the child's `stdout`/`stderr` pipes once the process tree is gone.
|
|
187
|
+
*
|
|
188
|
+
* WHY: A surviving grandchild (e.g. a server spawned by an `npx` wrapper) inherits the
|
|
189
|
+
* wrapper's pipe write ends, so Node never emits `'close'` for the wrapper and
|
|
190
|
+
* `StdioClientTransport.close()` burns its full 4000ms escalation budget waiting for the
|
|
191
|
+
* `'close'` event. Destroying the parent's read ends lets `close()` resolve immediately
|
|
192
|
+
* even when a descendant escaped the tree kill. `stdin` is left alone: it is our write
|
|
193
|
+
* end and the SDK still ends it as part of its own close sequence.
|
|
194
|
+
*/
|
|
195
|
+
export function destroyStdioStreams(transport: object | null | undefined): void {
|
|
196
|
+
if (!transport) return;
|
|
197
|
+
const record = childRecords.get(transport);
|
|
198
|
+
if (!record) return;
|
|
199
|
+
childRecords.delete(transport);
|
|
200
|
+
// WHY: Flush the buffered partial stderr line first. Destroying our read end keeps the
|
|
201
|
+
// SDK's PassThrough from ever emitting 'end', so without this a forced teardown drops the
|
|
202
|
+
// server's last words (the buffered remainder of a line with no trailing newline).
|
|
203
|
+
record.flushStderr();
|
|
204
|
+
record.process.stdout?.destroy();
|
|
205
|
+
record.process.stderr?.destroy();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Descendant Discovery ─────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Upper bound on tracked descendants.
|
|
212
|
+
*
|
|
213
|
+
* WHY: A wrapper that respawns children while we walk the tree could otherwise make
|
|
214
|
+
* discovery unbounded; teardown only needs the tree as it stood when we snapshotted it.
|
|
215
|
+
*/
|
|
216
|
+
const MAX_DESCENDANTS = 256;
|
|
217
|
+
|
|
218
|
+
/** Read the direct child PIDs of `pid` from Linux `/proc`. */
|
|
219
|
+
async function readProcChildren(pid: number): Promise<number[]> {
|
|
220
|
+
// WHY: /proc/<pid>/task/<tid>/children lists each thread's direct children, so a
|
|
221
|
+
// multithreaded wrapper is covered without scanning every process in /proc.
|
|
222
|
+
const taskDir = `/proc/${pid}/task`;
|
|
223
|
+
const tids = await readdir(taskDir);
|
|
224
|
+
const children: number[] = [];
|
|
225
|
+
for (const tid of tids) {
|
|
226
|
+
const raw = await readChildrenFile(`${taskDir}/${tid}/children`);
|
|
227
|
+
for (const token of raw.split(/\s+/)) {
|
|
228
|
+
if (token) children.push(Number(token));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return children;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function readChildrenFile(path: string): Promise<string> {
|
|
235
|
+
try {
|
|
236
|
+
return await readFile(path, "utf8");
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// WHY: A thread can exit between readdir() and readFile(), and a process that dies
|
|
239
|
+
// mid-walk simply has no children left to report.
|
|
240
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return "";
|
|
241
|
+
throw err;
|
|
107
242
|
}
|
|
108
243
|
}
|
|
109
244
|
|
|
245
|
+
/** Timeout for a single `ps` process-table snapshot on non-Linux POSIX systems. */
|
|
246
|
+
const PS_TIMEOUT_MS = 1_000;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Read the full `ps` process table and index it as `parentPid → childPids`.
|
|
250
|
+
*
|
|
251
|
+
* WHY: macOS and other non-Linux POSIX systems have no `/proc`; `ps -A -o pid=,ppid=`
|
|
252
|
+
* dumps the entire process table in one call so a single snapshot can serve the whole
|
|
253
|
+
* tree walk without spawning `ps` once per node.
|
|
254
|
+
*/
|
|
255
|
+
async function readPsProcessTable(): Promise<Map<number, number[]>> {
|
|
256
|
+
const { stdout } = await execFileAsync("ps", ["-A", "-o", "pid=,ppid="], {
|
|
257
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
258
|
+
timeout: PS_TIMEOUT_MS,
|
|
259
|
+
});
|
|
260
|
+
return indexProcessTable(stdout);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Index `ps -A -o pid=,ppid=` output as a parent PID → child PIDs map. */
|
|
264
|
+
export function indexProcessTable(stdout: string): Map<number, number[]> {
|
|
265
|
+
const childrenOf = new Map<number, number[]>();
|
|
266
|
+
for (const line of stdout.split("\n")) {
|
|
267
|
+
const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
|
|
268
|
+
if (!match) continue;
|
|
269
|
+
const childPid = Number(match[1]);
|
|
270
|
+
const parentPid = Number(match[2]);
|
|
271
|
+
const siblings = childrenOf.get(parentPid);
|
|
272
|
+
if (siblings) {
|
|
273
|
+
siblings.push(childPid);
|
|
274
|
+
} else {
|
|
275
|
+
childrenOf.set(parentPid, [childPid]);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return childrenOf;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function createPlatformChildReader(): (pid: number) => Promise<number[]> {
|
|
282
|
+
if (process.platform === "linux") return readProcChildren;
|
|
283
|
+
if (process.platform === "win32") {
|
|
284
|
+
// WHY: Windows has no `/proc` or `ps`, and the portable tree kill (`taskkill /T`)
|
|
285
|
+
// must be driven by the caller's own signal semantics. Discovery reports nothing so
|
|
286
|
+
// teardown falls back to killing the top-level PID plus destroying the pipes.
|
|
287
|
+
return () => Promise.resolve([]);
|
|
288
|
+
}
|
|
289
|
+
let tablePromise: Promise<Map<number, number[]>> | null = null;
|
|
290
|
+
return async (pid: number): Promise<number[]> => {
|
|
291
|
+
tablePromise ??= readPsProcessTable();
|
|
292
|
+
const childrenOf = await tablePromise;
|
|
293
|
+
return childrenOf.get(pid) ?? [];
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Snapshot the descendant PIDs of `pid`, deepest generation first.
|
|
299
|
+
*
|
|
300
|
+
* Returns `[]` when discovery is unavailable (Windows, containers without `ps` or
|
|
301
|
+
* `/proc`), which leaves the caller's single-PID teardown intact.
|
|
302
|
+
*/
|
|
303
|
+
export async function listDescendantPids(
|
|
304
|
+
pid: number,
|
|
305
|
+
readChildren: (pid: number) => Promise<number[]> = createPlatformChildReader(),
|
|
306
|
+
): Promise<number[]> {
|
|
307
|
+
try {
|
|
308
|
+
const descendants = await walkDescendants(pid, readChildren);
|
|
309
|
+
// Breadth-first order reversed: each generation is signalled before its parent, so a
|
|
310
|
+
// wrapper cannot respawn or reap a child that is still being torn down.
|
|
311
|
+
return descendants.reverse();
|
|
312
|
+
} catch {
|
|
313
|
+
// WHY: Discovery is best-effort by design: the PID can exit between the liveness check
|
|
314
|
+
// and the `/proc` walk, and minimal containers ship neither `ps` nor `/proc`. A tree we
|
|
315
|
+
// cannot read must never fail the caller's shutdown path — teardown still kills the
|
|
316
|
+
// root PID and releases the inherited pipes.
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function walkDescendants(
|
|
322
|
+
pid: number,
|
|
323
|
+
readChildren: (pid: number) => Promise<number[]>,
|
|
324
|
+
): Promise<number[]> {
|
|
325
|
+
const descendants: number[] = [];
|
|
326
|
+
const seen = new Set<number>([pid]);
|
|
327
|
+
const pending = await readChildren(pid);
|
|
328
|
+
while (pending.length > 0 && descendants.length < MAX_DESCENDANTS) {
|
|
329
|
+
const next = pending.shift();
|
|
330
|
+
if (next === undefined || seen.has(next)) continue;
|
|
331
|
+
seen.add(next);
|
|
332
|
+
descendants.push(next);
|
|
333
|
+
pending.push(...(await readChildren(next)));
|
|
334
|
+
}
|
|
335
|
+
return descendants;
|
|
336
|
+
}
|
|
337
|
+
|
|
110
338
|
// ─── Process Supervision ──────────────────────────────────────────────────────
|
|
111
339
|
|
|
112
340
|
export interface StdioProcessDeps {
|
|
113
341
|
kill(pid: number, signal?: NodeJS.Signals | number): void | boolean;
|
|
114
342
|
sleep(ms: number): Promise<void>;
|
|
115
343
|
now(): number;
|
|
344
|
+
/** Snapshot descendant PIDs of `pid`, deepest first. Resolves `[]` when unavailable. */
|
|
345
|
+
listDescendantPids(pid: number): Promise<number[]>;
|
|
116
346
|
}
|
|
117
347
|
|
|
118
348
|
const defaultDeps: StdioProcessDeps = {
|
|
119
349
|
kill: (pid, signal) => process.kill(pid, signal),
|
|
120
350
|
sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
121
351
|
now: () => Date.now(),
|
|
352
|
+
listDescendantPids: (pid) => listDescendantPids(pid),
|
|
122
353
|
};
|
|
123
354
|
|
|
124
355
|
function resolveDeps(deps?: Partial<StdioProcessDeps>): StdioProcessDeps {
|
|
@@ -127,6 +358,7 @@ function resolveDeps(deps?: Partial<StdioProcessDeps>): StdioProcessDeps {
|
|
|
127
358
|
kill: deps.kill ?? defaultDeps.kill,
|
|
128
359
|
sleep: deps.sleep ?? defaultDeps.sleep,
|
|
129
360
|
now: deps.now ?? defaultDeps.now,
|
|
361
|
+
listDescendantPids: deps.listDescendantPids ?? defaultDeps.listDescendantPids,
|
|
130
362
|
};
|
|
131
363
|
}
|
|
132
364
|
|
|
@@ -146,29 +378,68 @@ export function isPidAlive(
|
|
|
146
378
|
}
|
|
147
379
|
}
|
|
148
380
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
signal: NodeJS.Signals,
|
|
152
|
-
timeoutMs: number,
|
|
153
|
-
d: StdioProcessDeps,
|
|
154
|
-
): Promise<boolean> {
|
|
381
|
+
/** Send `signal` to one PID; `false` when the PID was already gone (ESRCH). */
|
|
382
|
+
function signalPid(pid: number, signal: NodeJS.Signals, d: StdioProcessDeps): boolean {
|
|
155
383
|
try {
|
|
156
384
|
d.kill(pid, signal);
|
|
385
|
+
return true;
|
|
157
386
|
} catch (err) {
|
|
158
387
|
if ((err as NodeJS.ErrnoException).code === "ESRCH") {
|
|
159
|
-
return
|
|
388
|
+
return false;
|
|
160
389
|
}
|
|
390
|
+
// WHY: EPERM and platform refusals mean this PID cannot be signalled (a recycled PID
|
|
391
|
+
// or a sandboxed process). Teardown still has to signal the rest of the tree within
|
|
392
|
+
// the bounded wait instead of rejecting the caller's shutdown path.
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function allExited(pids: readonly number[], d: StdioProcessDeps): boolean {
|
|
398
|
+
return pids.every((pid) => !isPidAlive(pid, d));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function signalAndWaitForExit(
|
|
402
|
+
pids: readonly number[],
|
|
403
|
+
signal: NodeJS.Signals,
|
|
404
|
+
timeoutMs: number,
|
|
405
|
+
d: StdioProcessDeps,
|
|
406
|
+
): Promise<boolean> {
|
|
407
|
+
const attempted = pids.map((pid) => signalPid(pid, signal, d));
|
|
408
|
+
// Every target reported ESRCH: the tree is already gone, so there is nothing to wait for.
|
|
409
|
+
if (!attempted.some(Boolean)) {
|
|
410
|
+
return true;
|
|
161
411
|
}
|
|
162
412
|
|
|
163
413
|
const deadline = d.now() + timeoutMs;
|
|
164
414
|
while (d.now() < deadline) {
|
|
165
|
-
if (
|
|
415
|
+
if (allExited(pids, d)) {
|
|
166
416
|
return true;
|
|
167
417
|
}
|
|
168
418
|
await d.sleep(25);
|
|
169
419
|
}
|
|
170
420
|
|
|
171
|
-
return
|
|
421
|
+
return allExited(pids, d);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Escalate to SIGKILL across the surviving tree.
|
|
426
|
+
*
|
|
427
|
+
* WHY: The wrapper can spawn new children while it ignores SIGTERM, so the tree is
|
|
428
|
+
* re-snapshotted; the original snapshot still covers descendants of a wrapper that
|
|
429
|
+
* already exited (they were re-parented to init and are no longer discoverable).
|
|
430
|
+
*/
|
|
431
|
+
async function escalateToSigkill(
|
|
432
|
+
rootPid: number,
|
|
433
|
+
descendants: readonly number[],
|
|
434
|
+
d: StdioProcessDeps,
|
|
435
|
+
): Promise<void> {
|
|
436
|
+
const rescanned = await d.listDescendantPids(rootPid);
|
|
437
|
+
const survivors = [...new Set([...rescanned, ...descendants])].filter((candidate) =>
|
|
438
|
+
isPidAlive(candidate, d),
|
|
439
|
+
);
|
|
440
|
+
const targets = [...survivors, rootPid].filter((candidate) => isPidAlive(candidate, d));
|
|
441
|
+
if (targets.length === 0) return;
|
|
442
|
+
await signalAndWaitForExit(targets, "SIGKILL", 200, d);
|
|
172
443
|
}
|
|
173
444
|
|
|
174
445
|
export async function ensurePidTerminated(
|
|
@@ -180,10 +451,12 @@ export async function ensurePidTerminated(
|
|
|
180
451
|
return;
|
|
181
452
|
}
|
|
182
453
|
|
|
183
|
-
|
|
454
|
+
// WHY: Snapshot before signalling any PID: a wrapper that exits first re-parents its
|
|
455
|
+
// children to init, after which they can no longer be found by parent PID.
|
|
456
|
+
const descendants = await d.listDescendantPids(pid);
|
|
457
|
+
if (await signalAndWaitForExit([...descendants, pid], "SIGTERM", 500, d)) {
|
|
184
458
|
return;
|
|
185
459
|
}
|
|
186
460
|
|
|
187
|
-
await
|
|
461
|
+
await escalateToSigkill(pid, descendants, d);
|
|
188
462
|
}
|
|
189
|
-
|