@namzu/sdk 14.0.3 → 14.0.4
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/CHANGELOG.md +20 -0
- package/dist/connector/mcp/__tests__/a-close-that-means-closed.test.d.ts +2 -0
- package/dist/connector/mcp/__tests__/a-close-that-means-closed.test.d.ts.map +1 -0
- package/dist/connector/mcp/__tests__/a-close-that-means-closed.test.js +106 -0
- package/dist/connector/mcp/__tests__/a-close-that-means-closed.test.js.map +1 -0
- package/dist/connector/mcp/stdio.d.ts.map +1 -1
- package/dist/connector/mcp/stdio.js +38 -1
- package/dist/connector/mcp/stdio.js.map +1 -1
- package/package.json +1 -1
- package/src/connector/mcp/__tests__/a-close-that-means-closed.test.ts +119 -0
- package/src/connector/mcp/stdio.ts +38 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 14.0.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b4a3fa7: `StdioTransport.close()` resolves when the child is gone, not when the signal
|
|
8
|
+
was sent.
|
|
9
|
+
|
|
10
|
+
It called `kill('SIGTERM')` and returned. `kill()` returns as soon as the
|
|
11
|
+
signal is delivered, so an awaited `close()` meant "SIGTERM is on its way", and
|
|
12
|
+
a caller that closed a transport and then deleted the child's working directory
|
|
13
|
+
raced the exit — reported as `EBUSY` from a real integration, not inferred. A
|
|
14
|
+
close that does not mean closed makes every teardown built on it a guess, and
|
|
15
|
+
the guess is only wrong sometimes.
|
|
16
|
+
|
|
17
|
+
`close()` now waits for the child's `exit`. A child ignoring SIGTERM is sent
|
|
18
|
+
SIGKILL after two seconds, and a second timer gives up waiting, so `close()`
|
|
19
|
+
cannot hang; both timers are unreferenced, so neither holds the event loop
|
|
20
|
+
open. A spawn that never produced a process emits `error` and no `exit`, and
|
|
21
|
+
that path settles too rather than waiting for an event that is not coming.
|
|
22
|
+
|
|
3
23
|
## 14.0.3
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"a-close-that-means-closed.test.d.ts","sourceRoot":"","sources":["../../../../src/connector/mcp/__tests__/a-close-that-means-closed.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { StdioTransport } from '../stdio.js';
|
|
3
|
+
/**
|
|
4
|
+
* `close()` sent SIGTERM and returned without waiting, so a resolved close
|
|
5
|
+
* meant "the signal is on its way", not "the child is gone". A caller that
|
|
6
|
+
* closed a transport and then deleted the child's working directory raced the
|
|
7
|
+
* exit and saw EBUSY — reported from a real integration, not inferred.
|
|
8
|
+
*
|
|
9
|
+
* A close that does not mean closed makes every teardown built on top of it a
|
|
10
|
+
* guess, and the guess is only wrong sometimes, which is the worst kind.
|
|
11
|
+
*
|
|
12
|
+
* These assert on the operating system rather than on the transport's own
|
|
13
|
+
* state: `process.kill(pid, 0)` throws ESRCH once the pid is gone, so the
|
|
14
|
+
* assertion cannot pass by the transport merely believing it closed.
|
|
15
|
+
*/
|
|
16
|
+
/** The child the transport spawned, read before `close()` clears the field. */
|
|
17
|
+
function childOf(transport) {
|
|
18
|
+
const child = transport.process;
|
|
19
|
+
if (!child)
|
|
20
|
+
throw new Error('transport spawned no process');
|
|
21
|
+
return child;
|
|
22
|
+
}
|
|
23
|
+
function pidOf(transport) {
|
|
24
|
+
const pid = childOf(transport).pid;
|
|
25
|
+
if (pid === undefined)
|
|
26
|
+
throw new Error('transport spawned no process');
|
|
27
|
+
return pid;
|
|
28
|
+
}
|
|
29
|
+
function isAlive(pid) {
|
|
30
|
+
try {
|
|
31
|
+
process.kill(pid, 0);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
describe('a close that means closed', () => {
|
|
39
|
+
it('does not resolve until the child is actually gone', async () => {
|
|
40
|
+
const transport = new StdioTransport({
|
|
41
|
+
type: 'stdio',
|
|
42
|
+
command: process.execPath,
|
|
43
|
+
args: ['-e', 'setInterval(() => {}, 1000)'],
|
|
44
|
+
});
|
|
45
|
+
await transport.connect();
|
|
46
|
+
const child = childOf(transport);
|
|
47
|
+
const pid = pidOf(transport);
|
|
48
|
+
expect(isAlive(pid)).toBe(true);
|
|
49
|
+
await transport.close();
|
|
50
|
+
// The reaped-ness of the child, not the liveness of the pid.
|
|
51
|
+
//
|
|
52
|
+
// `isAlive(pid)` alone is sound about the wrong thing: on Windows
|
|
53
|
+
// `kill('SIGTERM')` terminates the process synchronously, so the pid is
|
|
54
|
+
// already gone by the next line whether or not `close()` waited — the
|
|
55
|
+
// assertion passed under a deliberately reintroduced fire-and-forget
|
|
56
|
+
// kill, which is how this was caught. Node fills `exitCode`/`signalCode`
|
|
57
|
+
// only when it reaps the child and emits `exit`, a later tick, so these
|
|
58
|
+
// are non-null here exactly when `close()` awaited that event.
|
|
59
|
+
expect(child.exitCode !== null || child.signalCode !== null).toBe(true);
|
|
60
|
+
expect(isAlive(pid)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
it('returns rather than hanging when the command does not exist', async () => {
|
|
63
|
+
// A spawn that fails emits `error` and never `exit`. Waiting on `exit`
|
|
64
|
+
// alone would turn a bad command into a shutdown that never completes.
|
|
65
|
+
const transport = new StdioTransport({
|
|
66
|
+
type: 'stdio',
|
|
67
|
+
command: 'namzu-no-such-command-exists-here',
|
|
68
|
+
args: [],
|
|
69
|
+
});
|
|
70
|
+
await transport.connect();
|
|
71
|
+
await expect(transport.close()).resolves.toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
it('is safe to call twice', async () => {
|
|
74
|
+
const transport = new StdioTransport({
|
|
75
|
+
type: 'stdio',
|
|
76
|
+
command: process.execPath,
|
|
77
|
+
args: ['-e', 'setInterval(() => {}, 1000)'],
|
|
78
|
+
});
|
|
79
|
+
await transport.connect();
|
|
80
|
+
const pid = pidOf(transport);
|
|
81
|
+
await transport.close();
|
|
82
|
+
await transport.close();
|
|
83
|
+
expect(isAlive(pid)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
it('reports the exit to the handler registered for that session', async () => {
|
|
86
|
+
// The waiting must not swallow the notification the client depends on
|
|
87
|
+
// to learn the session ended.
|
|
88
|
+
const transport = new StdioTransport({
|
|
89
|
+
type: 'stdio',
|
|
90
|
+
command: process.execPath,
|
|
91
|
+
args: ['-e', 'setInterval(() => {}, 1000)'],
|
|
92
|
+
});
|
|
93
|
+
await transport.connect();
|
|
94
|
+
let closes = 0;
|
|
95
|
+
transport.onClose(() => {
|
|
96
|
+
closes++;
|
|
97
|
+
});
|
|
98
|
+
await transport.close();
|
|
99
|
+
// `close` on a ChildProcess follows `exit` by a tick once the stdio
|
|
100
|
+
// streams drain; give the loop that tick rather than asserting on a
|
|
101
|
+
// race.
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
103
|
+
expect(closes).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
//# sourceMappingURL=a-close-that-means-closed.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"a-close-that-means-closed.test.js","sourceRoot":"","sources":["../../../../src/connector/mcp/__tests__/a-close-that-means-closed.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stdio.d.ts","sourceRoot":"","sources":["../../../src/connector/mcp/stdio.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACX,iBAAiB,EACjB,uBAAuB,EACvB,YAAY,EACZ,MAAM,gCAAgC,CAAA;
|
|
1
|
+
{"version":3,"file":"stdio.d.ts","sourceRoot":"","sources":["../../../src/connector/mcp/stdio.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACX,iBAAiB,EACjB,uBAAuB,EACvB,YAAY,EACZ,MAAM,gCAAgC,CAAA;AAUvC,qBAAa,cAAe,YAAW,YAAY;IAWtC,OAAO,CAAC,QAAQ,CAAC,MAAM;IAVnC,OAAO,CAAC,OAAO,CAA4B;IAC3C,OAAO,CAAC,eAAe,CAAkD;IACzE,OAAO,CAAC,aAAa,CAAwB;IAC7C,OAAO,CAAC,aAAa,CAAoC;IACzD,OAAO,CAAC,SAAS,CAAQ;IACzB,6DAA6D;IAC7D,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,GAAG,CAAQ;gBAEU,MAAM,EAAE,uBAAuB;IAItD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAuCxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAkD5B;;;;;;;;OAQG;IACH,OAAO,CAAC,aAAa;IAMf,IAAI,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAQrD,SAAS,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,IAAI,GAAG,IAAI;IAI9D,OAAO,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAIlC,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI;IAI9C,WAAW,IAAI,OAAO;IAItB,OAAO,CAAC,aAAa;CAerB"}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { getRootLogger } from '../../utils/logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* How long a child gets to honour SIGTERM before SIGKILL. Two seconds is
|
|
5
|
+
* long enough for a server flushing a response and short enough that a
|
|
6
|
+
* shutdown does not read as a hang.
|
|
7
|
+
*/
|
|
8
|
+
const TERMINATE_GRACE_MS = 2_000;
|
|
3
9
|
export class StdioTransport {
|
|
4
10
|
config;
|
|
5
11
|
process = null;
|
|
@@ -60,9 +66,40 @@ export class StdioTransport {
|
|
|
60
66
|
}
|
|
61
67
|
this.connected = false;
|
|
62
68
|
this.exitPending = true;
|
|
63
|
-
this.process
|
|
69
|
+
const child = this.process;
|
|
64
70
|
this.process = null;
|
|
65
71
|
this.buffer = '';
|
|
72
|
+
// Resolve when the child is actually gone, not when the signal was
|
|
73
|
+
// sent. `kill()` returns as soon as the signal is delivered, so an
|
|
74
|
+
// awaited `close()` meant only "SIGTERM is on its way" — a caller that
|
|
75
|
+
// closed and then deleted the child's working directory raced the exit
|
|
76
|
+
// and saw EBUSY. A close that does not mean closed makes every
|
|
77
|
+
// teardown after it a guess.
|
|
78
|
+
//
|
|
79
|
+
// A spawn that never produced a process emits `error` and no `exit`,
|
|
80
|
+
// so both settle this, and two timers make a hang impossible: the
|
|
81
|
+
// first escalates to SIGKILL for a child ignoring SIGTERM, the second
|
|
82
|
+
// gives up waiting. Neither holds the event loop open.
|
|
83
|
+
if (child.pid === undefined)
|
|
84
|
+
return;
|
|
85
|
+
await new Promise((resolve) => {
|
|
86
|
+
let settled = false;
|
|
87
|
+
const finish = () => {
|
|
88
|
+
if (settled)
|
|
89
|
+
return;
|
|
90
|
+
settled = true;
|
|
91
|
+
clearTimeout(escalate);
|
|
92
|
+
clearTimeout(giveUp);
|
|
93
|
+
resolve();
|
|
94
|
+
};
|
|
95
|
+
child.once('exit', finish);
|
|
96
|
+
child.once('error', finish);
|
|
97
|
+
child.kill('SIGTERM');
|
|
98
|
+
const escalate = setTimeout(() => child.kill('SIGKILL'), TERMINATE_GRACE_MS);
|
|
99
|
+
const giveUp = setTimeout(finish, TERMINATE_GRACE_MS * 2);
|
|
100
|
+
escalate.unref?.();
|
|
101
|
+
giveUp.unref?.();
|
|
102
|
+
});
|
|
66
103
|
// The handlers deliberately survive this call. `process.on('close')`
|
|
67
104
|
// fires them when the process actually exits, and dropping them here
|
|
68
105
|
// would leave `MCPClient` believing it is still connected — so its next
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stdio.js","sourceRoot":"","sources":["../../../src/connector/mcp/stdio.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"stdio.js","sourceRoot":"","sources":["../../../src/connector/mcp/stdio.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { StdioTransport } from '../stdio.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `close()` sent SIGTERM and returned without waiting, so a resolved close
|
|
8
|
+
* meant "the signal is on its way", not "the child is gone". A caller that
|
|
9
|
+
* closed a transport and then deleted the child's working directory raced the
|
|
10
|
+
* exit and saw EBUSY — reported from a real integration, not inferred.
|
|
11
|
+
*
|
|
12
|
+
* A close that does not mean closed makes every teardown built on top of it a
|
|
13
|
+
* guess, and the guess is only wrong sometimes, which is the worst kind.
|
|
14
|
+
*
|
|
15
|
+
* These assert on the operating system rather than on the transport's own
|
|
16
|
+
* state: `process.kill(pid, 0)` throws ESRCH once the pid is gone, so the
|
|
17
|
+
* assertion cannot pass by the transport merely believing it closed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The child the transport spawned, read before `close()` clears the field. */
|
|
21
|
+
function childOf(transport: StdioTransport): ChildProcess {
|
|
22
|
+
const child = (transport as unknown as { process: ChildProcess | null }).process
|
|
23
|
+
if (!child) throw new Error('transport spawned no process')
|
|
24
|
+
return child
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pidOf(transport: StdioTransport): number {
|
|
28
|
+
const pid = childOf(transport).pid
|
|
29
|
+
if (pid === undefined) throw new Error('transport spawned no process')
|
|
30
|
+
return pid
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isAlive(pid: number): boolean {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0)
|
|
36
|
+
return true
|
|
37
|
+
} catch {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('a close that means closed', () => {
|
|
43
|
+
it('does not resolve until the child is actually gone', async () => {
|
|
44
|
+
const transport = new StdioTransport({
|
|
45
|
+
type: 'stdio',
|
|
46
|
+
command: process.execPath,
|
|
47
|
+
args: ['-e', 'setInterval(() => {}, 1000)'],
|
|
48
|
+
})
|
|
49
|
+
await transport.connect()
|
|
50
|
+
const child = childOf(transport)
|
|
51
|
+
const pid = pidOf(transport)
|
|
52
|
+
expect(isAlive(pid)).toBe(true)
|
|
53
|
+
|
|
54
|
+
await transport.close()
|
|
55
|
+
|
|
56
|
+
// The reaped-ness of the child, not the liveness of the pid.
|
|
57
|
+
//
|
|
58
|
+
// `isAlive(pid)` alone is sound about the wrong thing: on Windows
|
|
59
|
+
// `kill('SIGTERM')` terminates the process synchronously, so the pid is
|
|
60
|
+
// already gone by the next line whether or not `close()` waited — the
|
|
61
|
+
// assertion passed under a deliberately reintroduced fire-and-forget
|
|
62
|
+
// kill, which is how this was caught. Node fills `exitCode`/`signalCode`
|
|
63
|
+
// only when it reaps the child and emits `exit`, a later tick, so these
|
|
64
|
+
// are non-null here exactly when `close()` awaited that event.
|
|
65
|
+
expect(child.exitCode !== null || child.signalCode !== null).toBe(true)
|
|
66
|
+
expect(isAlive(pid)).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('returns rather than hanging when the command does not exist', async () => {
|
|
70
|
+
// A spawn that fails emits `error` and never `exit`. Waiting on `exit`
|
|
71
|
+
// alone would turn a bad command into a shutdown that never completes.
|
|
72
|
+
const transport = new StdioTransport({
|
|
73
|
+
type: 'stdio',
|
|
74
|
+
command: 'namzu-no-such-command-exists-here',
|
|
75
|
+
args: [],
|
|
76
|
+
})
|
|
77
|
+
await transport.connect()
|
|
78
|
+
|
|
79
|
+
await expect(transport.close()).resolves.toBeUndefined()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('is safe to call twice', async () => {
|
|
83
|
+
const transport = new StdioTransport({
|
|
84
|
+
type: 'stdio',
|
|
85
|
+
command: process.execPath,
|
|
86
|
+
args: ['-e', 'setInterval(() => {}, 1000)'],
|
|
87
|
+
})
|
|
88
|
+
await transport.connect()
|
|
89
|
+
const pid = pidOf(transport)
|
|
90
|
+
|
|
91
|
+
await transport.close()
|
|
92
|
+
await transport.close()
|
|
93
|
+
|
|
94
|
+
expect(isAlive(pid)).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('reports the exit to the handler registered for that session', async () => {
|
|
98
|
+
// The waiting must not swallow the notification the client depends on
|
|
99
|
+
// to learn the session ended.
|
|
100
|
+
const transport = new StdioTransport({
|
|
101
|
+
type: 'stdio',
|
|
102
|
+
command: process.execPath,
|
|
103
|
+
args: ['-e', 'setInterval(() => {}, 1000)'],
|
|
104
|
+
})
|
|
105
|
+
await transport.connect()
|
|
106
|
+
let closes = 0
|
|
107
|
+
transport.onClose(() => {
|
|
108
|
+
closes++
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
await transport.close()
|
|
112
|
+
// `close` on a ChildProcess follows `exit` by a tick once the stdio
|
|
113
|
+
// streams drain; give the loop that tick rather than asserting on a
|
|
114
|
+
// race.
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
116
|
+
|
|
117
|
+
expect(closes).toBe(1)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -6,6 +6,13 @@ import type {
|
|
|
6
6
|
} from '../../types/connector/index.js'
|
|
7
7
|
import { type Logger, getRootLogger } from '../../utils/logger.js'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* How long a child gets to honour SIGTERM before SIGKILL. Two seconds is
|
|
11
|
+
* long enough for a server flushing a response and short enough that a
|
|
12
|
+
* shutdown does not read as a hang.
|
|
13
|
+
*/
|
|
14
|
+
const TERMINATE_GRACE_MS = 2_000
|
|
15
|
+
|
|
9
16
|
export class StdioTransport implements MCPTransport {
|
|
10
17
|
private process: ChildProcess | null = null
|
|
11
18
|
private messageHandlers: Array<(message: MCPJsonRpcMessage) => void> = []
|
|
@@ -71,9 +78,39 @@ export class StdioTransport implements MCPTransport {
|
|
|
71
78
|
}
|
|
72
79
|
this.connected = false
|
|
73
80
|
this.exitPending = true
|
|
74
|
-
this.process
|
|
81
|
+
const child = this.process
|
|
75
82
|
this.process = null
|
|
76
83
|
this.buffer = ''
|
|
84
|
+
|
|
85
|
+
// Resolve when the child is actually gone, not when the signal was
|
|
86
|
+
// sent. `kill()` returns as soon as the signal is delivered, so an
|
|
87
|
+
// awaited `close()` meant only "SIGTERM is on its way" — a caller that
|
|
88
|
+
// closed and then deleted the child's working directory raced the exit
|
|
89
|
+
// and saw EBUSY. A close that does not mean closed makes every
|
|
90
|
+
// teardown after it a guess.
|
|
91
|
+
//
|
|
92
|
+
// A spawn that never produced a process emits `error` and no `exit`,
|
|
93
|
+
// so both settle this, and two timers make a hang impossible: the
|
|
94
|
+
// first escalates to SIGKILL for a child ignoring SIGTERM, the second
|
|
95
|
+
// gives up waiting. Neither holds the event loop open.
|
|
96
|
+
if (child.pid === undefined) return
|
|
97
|
+
await new Promise<void>((resolve) => {
|
|
98
|
+
let settled = false
|
|
99
|
+
const finish = (): void => {
|
|
100
|
+
if (settled) return
|
|
101
|
+
settled = true
|
|
102
|
+
clearTimeout(escalate)
|
|
103
|
+
clearTimeout(giveUp)
|
|
104
|
+
resolve()
|
|
105
|
+
}
|
|
106
|
+
child.once('exit', finish)
|
|
107
|
+
child.once('error', finish)
|
|
108
|
+
child.kill('SIGTERM')
|
|
109
|
+
const escalate = setTimeout(() => child.kill('SIGKILL'), TERMINATE_GRACE_MS)
|
|
110
|
+
const giveUp = setTimeout(finish, TERMINATE_GRACE_MS * 2)
|
|
111
|
+
escalate.unref?.()
|
|
112
|
+
giveUp.unref?.()
|
|
113
|
+
})
|
|
77
114
|
// The handlers deliberately survive this call. `process.on('close')`
|
|
78
115
|
// fires them when the process actually exits, and dropping them here
|
|
79
116
|
// would leave `MCPClient` believing it is still connected — so its next
|