mcp-compress-router 1.5.0 → 1.5.2
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/build/cli/router-runner.js +38 -2
- package/build/services/auth-errors.js +73 -0
- package/build/services/guided-error.js +1 -2
- package/build/services/index.js +3 -1
- package/build/services/invoke-with-recovery.js +4 -4
- package/build/services/server-connection.js +3 -3
- package/build/services/shutdown-coordinator.js +120 -0
- package/build/services/shutdown-triggers.js +57 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { resolveConfigPath, persistAuthRequirements, loadConfig, ServerConnection, invokeWithRecovery, buildCatalog, ShutdownCoordinator, installShutdownTriggers, } from '../services/index.js';
|
|
2
3
|
import { Logger } from '../utils/index.js';
|
|
3
4
|
import { createGetToolSchemaHandler, buildGetToolSchemaDescription, GetToolSchemaInputSchema, createInvokeToolHandler, InvokeToolInputSchema, } from '../tools/index.js';
|
|
4
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -43,6 +44,8 @@ async function connectAllServers(servers, configPath, logger) {
|
|
|
43
44
|
* @param connections - Live ServerConnection instances keyed by name.
|
|
44
45
|
* @param selectionByServer - Per-server tool selection for re-filtering.
|
|
45
46
|
* @param logger - Structured logger.
|
|
47
|
+
* @returns A cleanup function that closes the MCP server (and its stdio
|
|
48
|
+
* transport) during shutdown.
|
|
46
49
|
*/
|
|
47
50
|
async function startRouterServer(catalog, connections, selectionByServer, logger) {
|
|
48
51
|
const router = new McpServer({
|
|
@@ -67,6 +70,29 @@ async function startRouterServer(catalog, connections, selectionByServer, logger
|
|
|
67
70
|
const transport = new StdioServerTransport();
|
|
68
71
|
await router.connect(transport);
|
|
69
72
|
logger.info('Server started on stdio');
|
|
73
|
+
return async () => {
|
|
74
|
+
try {
|
|
75
|
+
await router.close();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Ignore close errors during shutdown — the process is exiting.
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Closes every downstream server connection in parallel. Each
|
|
84
|
+
* `ServerConnection.close()` drives the SDK's graduated kill (end stdin,
|
|
85
|
+
* SIGTERM, then SIGKILL) so spawned downstream servers are terminated
|
|
86
|
+
* rather than orphaned when the router exits.
|
|
87
|
+
*
|
|
88
|
+
* @param connections - Live ServerConnection instances keyed by name.
|
|
89
|
+
* @param logger - Structured logger.
|
|
90
|
+
*/
|
|
91
|
+
async function closeAllConnections(connections, logger) {
|
|
92
|
+
logger.info('Closing downstream server connections', {
|
|
93
|
+
servers: [...connections.keys()],
|
|
94
|
+
});
|
|
95
|
+
await Promise.all([...connections.values()].map((conn) => conn.close().catch(() => { })));
|
|
70
96
|
}
|
|
71
97
|
/**
|
|
72
98
|
* Runs the router: loads config, refreshes cached auth requirements,
|
|
@@ -108,5 +134,15 @@ export async function runRouter(configPath, verbose) {
|
|
|
108
134
|
compressionLevelByServer.set(server.name, server.compressionLevel);
|
|
109
135
|
}
|
|
110
136
|
const catalog = buildCatalog(discovered, selectionByServer, logger, compressionLevelByServer);
|
|
111
|
-
await startRouterServer(catalog, connections, selectionByServer, logger);
|
|
137
|
+
const closeRouter = await startRouterServer(catalog, connections, selectionByServer, logger);
|
|
138
|
+
const coordinator = new ShutdownCoordinator(logger);
|
|
139
|
+
coordinator.register(() => closeAllConnections(connections, logger));
|
|
140
|
+
coordinator.register(closeRouter);
|
|
141
|
+
installShutdownTriggers(coordinator, logger);
|
|
142
|
+
// Block here until a shutdown trigger fires (signal or stdin EOF), then
|
|
143
|
+
// force-exit so lingering grandchild pipes (e.g. browser processes
|
|
144
|
+
// forked by a downstream server) cannot keep the router alive. Without
|
|
145
|
+
// this, the router would linger as a ghost process forever.
|
|
146
|
+
await coordinator.whenShutdown();
|
|
147
|
+
process.exit(0);
|
|
112
148
|
}
|
|
@@ -20,3 +20,76 @@ export class GuidedAuthError extends Error {
|
|
|
20
20
|
this.serverName = serverName;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* HTTP status codes that indicate an authentication or authorization
|
|
25
|
+
* failure at the transport layer. The MCP SDK's streamable HTTP
|
|
26
|
+
* transport surfaces the upstream response status as a numeric `code`
|
|
27
|
+
* property on the thrown error — e.g. 401 when the server rejects a
|
|
28
|
+
* request without driving the SDK's `redirectToAuthorization` flow.
|
|
29
|
+
*/
|
|
30
|
+
const AUTH_ERROR_STATUSES = new Set([401, 403]);
|
|
31
|
+
/**
|
|
32
|
+
* Error message substrings that indicate a downstream OAuth /
|
|
33
|
+
* access-token failure rather than a network, transport, or tool-level
|
|
34
|
+
* error. These surface when a server rejects a request in the response
|
|
35
|
+
* body (e.g. with an OAuth error code) instead of returning a clean 401
|
|
36
|
+
* that would drive the SDK's `redirectToAuthorization` flow (which
|
|
37
|
+
* throws {@link GuidedAuthError} directly).
|
|
38
|
+
*
|
|
39
|
+
* Matching is case-insensitive to tolerate casing differences across
|
|
40
|
+
* SDK versions and server implementations.
|
|
41
|
+
*/
|
|
42
|
+
const AUTH_ERROR_PATTERNS = [
|
|
43
|
+
'invalid_token',
|
|
44
|
+
'invalid_grant',
|
|
45
|
+
'invalid_client',
|
|
46
|
+
'missing or invalid access token',
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* Reads the HTTP status code attached to an error, if any.
|
|
50
|
+
*
|
|
51
|
+
* The MCP SDK's `StreamableHTTPError` exposes the upstream HTTP status
|
|
52
|
+
* as a numeric `code` property. JSON-RPC error codes (e.g. `-32601`
|
|
53
|
+
* Method not found) are negative and never collide with HTTP status
|
|
54
|
+
* codes, so a positive value here is unambiguously an HTTP status.
|
|
55
|
+
* Duck-typed to avoid importing the SDK error class (mirrors the
|
|
56
|
+
* `isMethodNotFound` approach in `discovery.ts`).
|
|
57
|
+
*
|
|
58
|
+
* @param err - The thrown value from connect, reconnect, or invoke.
|
|
59
|
+
* @returns The HTTP status code, or `undefined` when none is present.
|
|
60
|
+
*/
|
|
61
|
+
function getHttpStatus(err) {
|
|
62
|
+
if (typeof err === 'object' && err !== null && 'code' in err) {
|
|
63
|
+
const code = err.code;
|
|
64
|
+
return typeof code === 'number' ? code : undefined;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Determines whether an error represents an authentication failure
|
|
70
|
+
* (missing, invalid, or expired OAuth credentials) rather than a
|
|
71
|
+
* network, transport, or tool-level error.
|
|
72
|
+
*
|
|
73
|
+
* Returns true for {@link GuidedAuthError} instances, for raw
|
|
74
|
+
* transport errors carrying an HTTP 401/403 status (read from the
|
|
75
|
+
* `code` property), and for errors whose message carries an OAuth
|
|
76
|
+
* error code such as `invalid_token` or `invalid_grant`. Without
|
|
77
|
+
* this, a server that rejects a request is misclassified as a generic
|
|
78
|
+
* connection failure, and the guided error tells the user to check
|
|
79
|
+
* their network instead of running `login`.
|
|
80
|
+
*
|
|
81
|
+
* @param err - The error thrown during connect, reconnect, or invoke.
|
|
82
|
+
* @returns True when the error indicates authentication is required.
|
|
83
|
+
*/
|
|
84
|
+
export function isAuthError(err) {
|
|
85
|
+
if (err instanceof GuidedAuthError) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
const status = getHttpStatus(err);
|
|
89
|
+
if (status !== undefined && AUTH_ERROR_STATUSES.has(status)) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
93
|
+
const lower = message.toLowerCase();
|
|
94
|
+
return AUTH_ERROR_PATTERNS.some((pattern) => lower.includes(pattern));
|
|
95
|
+
}
|
|
@@ -95,8 +95,7 @@ function restartGuidance(isStdio) {
|
|
|
95
95
|
: 'If the issue persists, restart the MCP server in your coding agent.';
|
|
96
96
|
return (restartClause +
|
|
97
97
|
' If you have already fixed the issue, restart the MCP server in your ' +
|
|
98
|
-
'coding agent
|
|
99
|
-
're-initializes the connection.');
|
|
98
|
+
'coding agent so it re-initializes the connection.');
|
|
100
99
|
}
|
|
101
100
|
function extractMessage(err) {
|
|
102
101
|
if (err instanceof Error) {
|
package/build/services/index.js
CHANGED
|
@@ -2,10 +2,12 @@ export { buildCatalog, lookupTools } from './catalog.js';
|
|
|
2
2
|
export { resolveConfigDir, resolveConfigPath, loadConfig } from './config.js';
|
|
3
3
|
export { discoverSingleServer } from './discovery.js';
|
|
4
4
|
export { ServerConnection } from './server-connection.js';
|
|
5
|
+
export { ShutdownCoordinator } from './shutdown-coordinator.js';
|
|
6
|
+
export { installShutdownTriggers } from './shutdown-triggers.js';
|
|
5
7
|
export { invokeWithRecovery } from './invoke-with-recovery.js';
|
|
6
8
|
export { saveToolCache } from './tool-cache.js';
|
|
7
9
|
export { OAuthCredentialManager } from './oauth.js';
|
|
8
10
|
export { computeAuthStatus, persistAuthRequirements } from './auth-status.js';
|
|
9
11
|
export { discoverAuth } from './oauth-discovery.js';
|
|
10
|
-
export { GuidedAuthError } from './auth-errors.js';
|
|
12
|
+
export { GuidedAuthError, isAuthError } from './auth-errors.js';
|
|
11
13
|
export { buildGuidedError } from './guided-error.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { updateServerInCatalog } from './catalog.js';
|
|
2
|
-
import { buildGuidedError,
|
|
2
|
+
import { buildGuidedError, isAuthError } from './index.js';
|
|
3
3
|
/**
|
|
4
4
|
* Set of error message substrings that indicate a recoverable
|
|
5
5
|
* network/transport failure (as opposed to a tool-level or protocol
|
|
@@ -30,7 +30,7 @@ const RECOVERABLE_PATTERNS = [
|
|
|
30
30
|
* @returns True when a reconnect + retry might succeed.
|
|
31
31
|
*/
|
|
32
32
|
export function isRecoverable(err) {
|
|
33
|
-
if (err
|
|
33
|
+
if (isAuthError(err)) {
|
|
34
34
|
return true;
|
|
35
35
|
}
|
|
36
36
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -154,7 +154,7 @@ async function invokeToolWithRetry(conn, tool, args, server, serverConfig, selec
|
|
|
154
154
|
// server is reachable, so 'unavailable' would be misleading —
|
|
155
155
|
// surface auth errors as 'authentication required', re-throw
|
|
156
156
|
// the rest as-is so the caller sees the real downstream error.
|
|
157
|
-
if (retryErr
|
|
157
|
+
if (isAuthError(retryErr)) {
|
|
158
158
|
throw buildGuidedError(serverConfig, retryErr, 'unauthorized', true);
|
|
159
159
|
}
|
|
160
160
|
throw retryErr;
|
|
@@ -162,7 +162,7 @@ async function invokeToolWithRetry(conn, tool, args, server, serverConfig, selec
|
|
|
162
162
|
// Reconnect itself failed: the server is down or requires auth.
|
|
163
163
|
// doReconnect has already transitioned the connection's status
|
|
164
164
|
// + cooldown so subsequent calls back off.
|
|
165
|
-
const status = retryErr
|
|
165
|
+
const status = isAuthError(retryErr) ? 'unauthorized' : 'unavailable';
|
|
166
166
|
throw buildGuidedError(serverConfig, retryErr, status, true);
|
|
167
167
|
}
|
|
168
168
|
}
|
|
@@ -2,7 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
|
2
2
|
import { createTransport, listToolsOrEmpty } from './discovery.js';
|
|
3
3
|
import { OAuthCredentialManager } from './oauth.js';
|
|
4
4
|
import { saveToolCache, loadToolCache } from './tool-cache.js';
|
|
5
|
-
import {
|
|
5
|
+
import { isAuthError } from './index.js';
|
|
6
6
|
/**
|
|
7
7
|
* 30-second cooldown after a failed reconnect attempt. Subsequent
|
|
8
8
|
* `invokeWithRecovery` calls within this window return the cached
|
|
@@ -122,7 +122,7 @@ export class ServerConnection {
|
|
|
122
122
|
cause: err,
|
|
123
123
|
});
|
|
124
124
|
}
|
|
125
|
-
const status = err
|
|
125
|
+
const status = isAuthError(err) ? 'unauthorized' : 'unavailable';
|
|
126
126
|
this._status = status;
|
|
127
127
|
this.logger.warn(`Server "${this.server.name}" failed (${status}) — using ${cachedTools.length} cached tools`, { server: this.server.name, type: this.server.type, error: message, status });
|
|
128
128
|
return {
|
|
@@ -202,7 +202,7 @@ export class ServerConnection {
|
|
|
202
202
|
// backoff. Auth failures are classified as 'unauthorized' so
|
|
203
203
|
// guided errors point at `login` instead of "connection failed".
|
|
204
204
|
this._lastError = err instanceof Error ? err.message : String(err);
|
|
205
|
-
this._status = err
|
|
205
|
+
this._status = isAuthError(err) ? 'unauthorized' : 'unavailable';
|
|
206
206
|
throw err;
|
|
207
207
|
}
|
|
208
208
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Default overall budget for running every registered cleanup hook. */
|
|
2
|
+
const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000;
|
|
3
|
+
/**
|
|
4
|
+
* Coordinates graceful shutdown of the router. Downstream resources
|
|
5
|
+
* (server connections, the MCP server) register async cleanup hooks; a
|
|
6
|
+
* single trigger — a process signal or the disappearance of stdin — runs
|
|
7
|
+
* every hook once (each racing a shared timeout) and resolves the awaited
|
|
8
|
+
* {@link ShutdownCoordinator.whenShutdown} promise so the entry point can
|
|
9
|
+
* force-exit.
|
|
10
|
+
*
|
|
11
|
+
* This is the fix for ghost router processes that linger forever: without
|
|
12
|
+
* it, the spawned downstream servers (and their own child processes, e.g.
|
|
13
|
+
* browser processes forked by a downstream server) keep the Node event
|
|
14
|
+
* loop alive even after the host closes the router's stdin pipe, so the
|
|
15
|
+
* router never exits on its own.
|
|
16
|
+
*/
|
|
17
|
+
export class ShutdownCoordinator {
|
|
18
|
+
logger;
|
|
19
|
+
cleanupTimeoutMs;
|
|
20
|
+
cleanups = [];
|
|
21
|
+
resolveShutdown;
|
|
22
|
+
shutdownPromise = new Promise((resolve) => {
|
|
23
|
+
this.resolveShutdown = resolve;
|
|
24
|
+
});
|
|
25
|
+
triggered = false;
|
|
26
|
+
/**
|
|
27
|
+
* @param logger - Structured logger for shutdown diagnostics.
|
|
28
|
+
* @param cleanupTimeoutMs - Overall budget for running every hook
|
|
29
|
+
* before the coordinator stops waiting and lets the process exit.
|
|
30
|
+
* Defaults to 5 seconds; overridable (mainly for tests).
|
|
31
|
+
*/
|
|
32
|
+
constructor(logger, cleanupTimeoutMs = DEFAULT_CLEANUP_TIMEOUT_MS) {
|
|
33
|
+
this.logger = logger;
|
|
34
|
+
this.cleanupTimeoutMs = cleanupTimeoutMs;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether shutdown has already been triggered. A `true` value means
|
|
38
|
+
* cleanup hooks are running (or have run); newly registered hooks are
|
|
39
|
+
* ignored from this point on.
|
|
40
|
+
*/
|
|
41
|
+
get isShuttingDown() {
|
|
42
|
+
return this.triggered;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Registers a cleanup hook. Hooks added after shutdown has already
|
|
46
|
+
* started are ignored — the process is already tearing down.
|
|
47
|
+
*
|
|
48
|
+
* @param cleanup - Async function run once during shutdown.
|
|
49
|
+
*/
|
|
50
|
+
register(cleanup) {
|
|
51
|
+
if (this.triggered) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.cleanups.push(cleanup);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Triggers shutdown: runs every registered hook (each racing the shared
|
|
58
|
+
* cleanup timeout), never rejecting. Safe to call repeatedly — only the
|
|
59
|
+
* first call runs the hooks; later calls return the same promise.
|
|
60
|
+
*
|
|
61
|
+
* @param reason - Why shutdown was triggered (e.g. `signal:SIGTERM`,
|
|
62
|
+
* `stdin-closed`).
|
|
63
|
+
* @returns Resolves when all hooks finish or time out.
|
|
64
|
+
*/
|
|
65
|
+
shutdown(reason) {
|
|
66
|
+
if (this.triggered) {
|
|
67
|
+
return this.shutdownPromise;
|
|
68
|
+
}
|
|
69
|
+
this.triggered = true;
|
|
70
|
+
this.logger.info('Shutdown triggered', { reason });
|
|
71
|
+
void this.runCleanups(reason).finally(() => this.resolveShutdown());
|
|
72
|
+
return this.shutdownPromise;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolves once shutdown has completed. The router's main loop awaits
|
|
76
|
+
* this so the process stays alive while serving and exits the moment
|
|
77
|
+
* cleanup finishes. Does NOT trigger shutdown on its own.
|
|
78
|
+
*/
|
|
79
|
+
whenShutdown() {
|
|
80
|
+
return this.shutdownPromise;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Runs every hook in parallel. A slow or throwing hook never blocks the
|
|
84
|
+
* others or the final exit: each is raced against the shared deadline,
|
|
85
|
+
* and rejections are logged and swallowed.
|
|
86
|
+
*/
|
|
87
|
+
async runCleanups(reason) {
|
|
88
|
+
const deadline = this.createDeadline();
|
|
89
|
+
await Promise.all(this.cleanups.map((cleanup, index) => this.runOne(cleanup, index, reason, deadline)));
|
|
90
|
+
this.logger.info('Shutdown complete', { reason });
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Runs a single hook against the shared deadline. Defers invocation so
|
|
94
|
+
* a synchronous throw becomes a rejection we can race and swallow
|
|
95
|
+
* rather than an uncaught exception.
|
|
96
|
+
*/
|
|
97
|
+
async runOne(cleanup, index, reason, deadline) {
|
|
98
|
+
const pending = Promise.resolve().then(() => cleanup());
|
|
99
|
+
// Swallow late rejections so a hook that fails after the deadline
|
|
100
|
+
// already resolved never surfaces an unhandled-rejection warning.
|
|
101
|
+
void pending.catch(() => { });
|
|
102
|
+
try {
|
|
103
|
+
await Promise.race([pending, deadline]);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
this.logger.warn('Cleanup hook failed during shutdown', {
|
|
107
|
+
reason,
|
|
108
|
+
hook: index,
|
|
109
|
+
error: err instanceof Error ? err.message : String(err),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Creates an unref'd timer that resolves after the cleanup budget. */
|
|
114
|
+
createDeadline() {
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
const handle = setTimeout(resolve, this.cleanupTimeoutMs);
|
|
117
|
+
handle.unref?.();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
/**
|
|
3
|
+
* Signals whose arrival should trigger a graceful shutdown. `SIGHUP` is
|
|
4
|
+
* included because some hosts send it when the controlling terminal
|
|
5
|
+
* disappears.
|
|
6
|
+
*/
|
|
7
|
+
const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
8
|
+
/**
|
|
9
|
+
* Installs process-global triggers that call
|
|
10
|
+
* {@link ShutdownCoordinator.shutdown} when the router should stop:
|
|
11
|
+
*
|
|
12
|
+
* - `SIGINT` / `SIGTERM` / `SIGHUP` — the host asked us to terminate.
|
|
13
|
+
* - stdin `'end'` / `'close'` — the host closed our input pipe without
|
|
14
|
+
* sending a signal. This is the most common cause of ghost router
|
|
15
|
+
* processes that never exit: the MCP SDK's stdio server transport only
|
|
16
|
+
* listens for `'data'` / `'error'` on stdin, so without this hook the
|
|
17
|
+
* router lingers forever while spawned downstream servers keep the
|
|
18
|
+
* event loop alive.
|
|
19
|
+
*
|
|
20
|
+
* A second shutdown signal force-exits immediately so that a stuck
|
|
21
|
+
* cleanup never traps the user with an unresponsive process.
|
|
22
|
+
*
|
|
23
|
+
* @param coordinator - The coordinator to trigger on signal or stdin EOF.
|
|
24
|
+
* @param logger - Structured logger for trigger diagnostics.
|
|
25
|
+
*/
|
|
26
|
+
export function installShutdownTriggers(coordinator, logger) {
|
|
27
|
+
for (const signal of SHUTDOWN_SIGNALS) {
|
|
28
|
+
process.on(signal, () => {
|
|
29
|
+
if (coordinator.isShuttingDown) {
|
|
30
|
+
logger.warn('Forcing immediate exit on signal during shutdown', { signal });
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
void coordinator.shutdown(`signal:${signal}`);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
watchStdinClose(coordinator);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Treats stdin EOF as a shutdown trigger. The host (IDE/editor/agent)
|
|
40
|
+
* typically ends the router by closing its stdin pipe without sending a
|
|
41
|
+
* signal; without this watcher the router would linger as a ghost
|
|
42
|
+
* process because the SDK ignores stdin EOF.
|
|
43
|
+
*
|
|
44
|
+
* Listeners are removed after the first trigger so a later `'close'`
|
|
45
|
+
* event (e.g. from the SDK tearing the transport down during cleanup)
|
|
46
|
+
* cannot re-enter the coordinator.
|
|
47
|
+
*/
|
|
48
|
+
function watchStdinClose(coordinator) {
|
|
49
|
+
const stdin = process.stdin;
|
|
50
|
+
const trigger = () => {
|
|
51
|
+
stdin.off('end', trigger);
|
|
52
|
+
stdin.off('close', trigger);
|
|
53
|
+
void coordinator.shutdown('stdin-closed');
|
|
54
|
+
};
|
|
55
|
+
stdin.on('end', trigger);
|
|
56
|
+
stdin.on('close', trigger);
|
|
57
|
+
}
|