amicus 1.7.6 → 1.8.0
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/.claude-plugin/plugin.json +27 -6
- package/CHANGELOG.md +65 -0
- package/LICENSE +2 -22
- package/README.md +38 -5
- package/bin/amicus.js +9 -4
- package/package.json +1 -1
- package/scripts/postinstall.js +72 -23
- package/src/cli-handlers-doctor.js +43 -0
- package/src/cli-handlers-status.js +76 -0
- package/src/cli-handlers.js +32 -0
- package/src/cli.js +8 -0
- package/src/mcp-server.js +124 -21
- package/src/mcp-tools.js +28 -1
- package/src/mcp-wait.js +163 -0
- package/src/sidecar/continue.js +24 -6
- package/src/sidecar/conversation-mirror.js +17 -4
- package/src/sidecar/interactive-abort.js +112 -0
- package/src/sidecar/interactive.js +32 -2
- package/src/sidecar/progress-fields.js +60 -0
- package/src/sidecar/progress.js +52 -48
- package/src/sidecar/resume.js +23 -7
- package/src/sidecar/start.js +6 -7
- package/src/utils/abort-coordinator.js +91 -0
- package/src/utils/error-doc.js +3 -0
- package/src/utils/legacy-mcp-migration.js +119 -0
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +6 -9
- package/src/utils/mcp-self-identity.js +69 -0
- package/src/utils/remediation-hints.js +8 -0
- package/src/utils/shared-server.js +50 -18
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// src/utils/legacy-mcp-migration.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Legacy 'sidecar' MCP registration cleanup (Phase 4 tool de-bloat).
|
|
6
|
+
*
|
|
7
|
+
* Through v1.7.x scripts/postinstall.js registered the SAME stdio MCP server
|
|
8
|
+
* under two names — 'amicus' and legacy 'sidecar' — in Claude Code
|
|
9
|
+
* (~/.claude.json) and Claude Desktop/Cowork (claude_desktop_config.json).
|
|
10
|
+
* Combined with the in-server sidecar_* tool aliases this quadrupled the
|
|
11
|
+
* client-visible tool surface (13 real tools -> 52).
|
|
12
|
+
*
|
|
13
|
+
* This module removes a legacy 'sidecar' server entry, but ONLY when it is
|
|
14
|
+
* identical-in-effect to the amicus registration: its command must resolve to
|
|
15
|
+
* an amicus MCP invocation per isAmicusMcpConfig() (./mcp-self-identity,
|
|
16
|
+
* Phase 1). A 'sidecar' entry pointing anywhere else is user customization
|
|
17
|
+
* and is NEVER touched.
|
|
18
|
+
*
|
|
19
|
+
* Consumers: scripts/postinstall.js (one-shot migration on install/upgrade)
|
|
20
|
+
* and src/cli-handlers-doctor.js (duplicate check + `doctor --fix`).
|
|
21
|
+
* All functions are synchronous, never throw, and report via return values.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const os = require('os');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { writeFileAtomic } = require('./atomic-write');
|
|
28
|
+
|
|
29
|
+
/** ~/.claude.json — where BOTH Claude Code registration paths (CLI + file fallback) land. */
|
|
30
|
+
function claudeCodeConfigPath() {
|
|
31
|
+
return path.join(os.homedir(), '.claude.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** claude_desktop_config.json — platform-aware; mirrors postinstall registerClaudeDesktop. */
|
|
35
|
+
function claudeDesktopConfigPath() {
|
|
36
|
+
if (process.platform === 'darwin') {
|
|
37
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
38
|
+
}
|
|
39
|
+
if (process.platform === 'win32') {
|
|
40
|
+
return path.join(process.env.APPDATA || '', 'Claude', 'claude_desktop_config.json');
|
|
41
|
+
}
|
|
42
|
+
return path.join(os.homedir(), '.config', 'claude', 'claude_desktop_config.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function defaultTargets(deps = {}) {
|
|
46
|
+
return [
|
|
47
|
+
{ target: 'Claude Code', configPath: deps.codePath || claudeCodeConfigPath() },
|
|
48
|
+
{ target: 'Claude Desktop', configPath: deps.desktopPath || claudeDesktopConfigPath() },
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Inspect one config file for a legacy 'sidecar' MCP entry.
|
|
54
|
+
* @returns {{status:'absent'|'removable'|'customized'|'unreadable', config?:object}}
|
|
55
|
+
*/
|
|
56
|
+
function inspectLegacySidecarEntry(configPath, deps = {}) {
|
|
57
|
+
const isAmicus = deps.isAmicusMcpConfig
|
|
58
|
+
|| require('./mcp-self-identity').isAmicusMcpConfig;
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
if (!fs.existsSync(configPath)) { return { status: 'absent' }; }
|
|
62
|
+
parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
63
|
+
} catch {
|
|
64
|
+
return { status: 'unreadable' };
|
|
65
|
+
}
|
|
66
|
+
const entry = parsed && parsed.mcpServers ? parsed.mcpServers.sidecar : undefined;
|
|
67
|
+
if (!entry) { return { status: 'absent' }; }
|
|
68
|
+
// 'removable' means VERIFIED duplicate, not just amicus-shaped. Two edges
|
|
69
|
+
// must fall back to 'customized' (left alone) instead:
|
|
70
|
+
// (a) no 'amicus' twin present — this may be the user's ONLY working
|
|
71
|
+
// registration (scripts-skipped / manual pre-rebrand install); deleting
|
|
72
|
+
// it would leave them with nothing.
|
|
73
|
+
// (b) the sidecar entry carries a non-empty `env` (API keys,
|
|
74
|
+
// AMICUS_LEGACY_ALIASES itself) — not identical-in-effect to the bare
|
|
75
|
+
// 'amicus' entry, so removing it would silently lose configuration.
|
|
76
|
+
const hasAmicusTwin = !!(parsed.mcpServers && parsed.mcpServers.amicus);
|
|
77
|
+
const hasNonEmptyEnv = !!(entry.env && typeof entry.env === 'object' && Object.keys(entry.env).length > 0);
|
|
78
|
+
if (!hasAmicusTwin || hasNonEmptyEnv) { return { status: 'customized', config: entry }; }
|
|
79
|
+
return isAmicus(entry)
|
|
80
|
+
? { status: 'removable', config: entry }
|
|
81
|
+
: { status: 'customized', config: entry };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Remove the legacy 'sidecar' entry from one config file — ONLY when it is an
|
|
86
|
+
* amicus self-invocation. Preserves every other key in the file.
|
|
87
|
+
* @returns {'absent'|'removed'|'customized'|'unreadable'|'write-failed'}
|
|
88
|
+
*/
|
|
89
|
+
function removeLegacySidecarEntry(configPath, deps = {}) {
|
|
90
|
+
const inspected = inspectLegacySidecarEntry(configPath, deps);
|
|
91
|
+
if (inspected.status !== 'removable') { return inspected.status; }
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
94
|
+
delete parsed.mcpServers.sidecar;
|
|
95
|
+
// Atomic temp+rename write — a crash mid-write must never corrupt the
|
|
96
|
+
// user's main Claude Code state file. 0o600 is a no-op on NTFS; kept for
|
|
97
|
+
// parity with addMcpToConfigFile.
|
|
98
|
+
writeFileAtomic(configPath, JSON.stringify(parsed, null, 2), { mode: 0o600 });
|
|
99
|
+
return 'removed';
|
|
100
|
+
} catch {
|
|
101
|
+
return 'write-failed';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Inspect every known registry (doctor check). */
|
|
106
|
+
function inspectAllLegacySidecarEntries(deps = {}) {
|
|
107
|
+
return defaultTargets(deps).map((t) => ({ ...t, ...inspectLegacySidecarEntry(t.configPath, deps) }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Remove identical-in-effect legacy entries everywhere. Idempotent. */
|
|
111
|
+
function migrateLegacySidecar(deps = {}) {
|
|
112
|
+
return defaultTargets(deps).map((t) => ({ ...t, result: removeLegacySidecarEntry(t.configPath, deps) }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
claudeCodeConfigPath, claudeDesktopConfigPath,
|
|
117
|
+
inspectLegacySidecarEntry, removeLegacySidecarEntry,
|
|
118
|
+
inspectAllLegacySidecarEntries, migrateLegacySidecar,
|
|
119
|
+
};
|
package/src/utils/lifecycle.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// when done (F3 #15). Deliberately EXCLUDED: `mcp` (long-lived server), and
|
|
13
13
|
// `setup`/`update` (no OpenCode server to leak, and `setup` can be a long-lived
|
|
14
14
|
// interactive Electron flow that must never be force-exited).
|
|
15
|
-
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
|
|
15
|
+
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'status', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
|
|
16
16
|
|
|
17
17
|
/** @param {string} command @returns {boolean} */
|
|
18
18
|
function isOneShotCommand(command) {
|
|
@@ -12,6 +12,7 @@ const fs = require('fs');
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const os = require('os');
|
|
14
14
|
const { logger } = require('./logger');
|
|
15
|
+
const { stripSelfMcpEntries } = require('./mcp-self-identity');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Normalize .mcp.json to a flat { name: config } map.
|
|
@@ -70,15 +71,13 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
|
|
|
70
71
|
const settingsPath = path.join(baseDir, 'settings.json');
|
|
71
72
|
if (!fs.existsSync(settingsPath)) {
|
|
72
73
|
// No settings.json — skip plugin discovery, may still have claude.json servers
|
|
73
|
-
const merged = { ...claudeJsonServers };
|
|
74
|
-
delete merged.sidecar;
|
|
74
|
+
const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
|
|
75
75
|
return Object.keys(merged).length > 0 ? merged : null;
|
|
76
76
|
}
|
|
77
77
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
78
78
|
const enabledPlugins = settings.enabledPlugins;
|
|
79
79
|
if (!enabledPlugins || typeof enabledPlugins !== 'object') {
|
|
80
|
-
const merged = { ...claudeJsonServers };
|
|
81
|
-
delete merged.sidecar;
|
|
80
|
+
const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
|
|
82
81
|
return Object.keys(merged).length > 0 ? merged : null;
|
|
83
82
|
}
|
|
84
83
|
|
|
@@ -133,11 +132,9 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
|
|
|
133
132
|
logger.debug('Failed to read Claude Code settings', { error: err.message });
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
// Merge: plugin servers first, then claude.json overwrites (higher priority)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// Always exclude sidecar itself to prevent recursive spawning
|
|
140
|
-
delete merged.sidecar;
|
|
135
|
+
// Merge: plugin servers first, then claude.json overwrites (higher priority).
|
|
136
|
+
// Recursive-spawn guard: drop every entry that resolves to amicus itself.
|
|
137
|
+
const merged = stripSelfMcpEntries({ ...pluginServers, ...claudeJsonServers }, logger);
|
|
141
138
|
|
|
142
139
|
return Object.keys(merged).length > 0 ? merged : null;
|
|
143
140
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module mcp-self-identity
|
|
5
|
+
* Recursive-spawn guard. A child sidecar that inherits an MCP entry launching
|
|
6
|
+
* amicus itself would spawn amicus inside amicus, forever. The shipped server
|
|
7
|
+
* registers as 'amicus' (scripts/postinstall.js, .claude-plugin/plugin.json)
|
|
8
|
+
* plus a deprecated 'sidecar' shim — and users can alias it under ANY name —
|
|
9
|
+
* so we exclude both reserved names AND any entry whose command+args resolve
|
|
10
|
+
* to an amicus MCP invocation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Server names amicus registers itself under. */
|
|
14
|
+
const SELF_MCP_NAMES = Object.freeze(['amicus', 'sidecar']);
|
|
15
|
+
|
|
16
|
+
/** Shipped bin aliases (package.json "bin") → ./bin/amicus.js */
|
|
17
|
+
const SELF_BIN_NAMES = new Set(['amicus', 'am', 'sidecar', 'claude-sidecar']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalize one command/arg token for identity matching: lower-case, forward
|
|
21
|
+
* slashes, basename, strip a trailing .exe/.cmd/.js, strip an @version spec.
|
|
22
|
+
* 'C:\\x\\bin\\amicus.js' → 'amicus'; 'amicus@latest' → 'amicus'; 'npx' → 'npx'.
|
|
23
|
+
* @param {unknown} token
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function normalizeToken(token) {
|
|
27
|
+
const t = String(token).toLowerCase().replace(/\\/g, '/');
|
|
28
|
+
const base = t.includes('/') ? t.slice(t.lastIndexOf('/') + 1) : t;
|
|
29
|
+
return base.replace(/\.(exe|cmd|js)$/, '').replace(/@[^@]*$/, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True when this MCP server config would launch amicus's own MCP server:
|
|
34
|
+
* some non-flag token resolves to an amicus binary/package and a LATER token
|
|
35
|
+
* is 'mcp'. URL-only (command-less) configs are never self.
|
|
36
|
+
* @param {{command?:string, args?:unknown[]}|null|undefined} config
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isAmicusMcpConfig(config) {
|
|
40
|
+
if (!config || typeof config !== 'object' || !config.command) { return false; }
|
|
41
|
+
const tokens = [config.command, ...(Array.isArray(config.args) ? config.args : [])].map(String);
|
|
42
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
43
|
+
if (tokens[i].startsWith('-')) { continue; } // flags (-y, --yes) are never the binary
|
|
44
|
+
if (SELF_BIN_NAMES.has(normalizeToken(tokens[i]))) {
|
|
45
|
+
return tokens.slice(i + 1).some((t) => String(t).toLowerCase() === 'mcp');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Delete every self entry (reserved name OR command identity) from an
|
|
53
|
+
* mcpServers map. Mutates and returns the same object.
|
|
54
|
+
* @param {object|null|undefined} mcpServers
|
|
55
|
+
* @param {{debug?:Function}} [log]
|
|
56
|
+
* @returns {object|null|undefined}
|
|
57
|
+
*/
|
|
58
|
+
function stripSelfMcpEntries(mcpServers, log) {
|
|
59
|
+
if (!mcpServers || typeof mcpServers !== 'object') { return mcpServers; }
|
|
60
|
+
for (const name of Object.keys(mcpServers)) {
|
|
61
|
+
if (SELF_MCP_NAMES.includes(name) || isAmicusMcpConfig(mcpServers[name])) {
|
|
62
|
+
delete mcpServers[name];
|
|
63
|
+
if (log && log.debug) { log.debug('Auto-excluded amicus MCP entry (recursive spawn prevention)', { name }); }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return mcpServers;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { SELF_MCP_NAMES, isAmicusMcpConfig, stripSelfMcpEntries, normalizeToken };
|
|
@@ -64,6 +64,14 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
64
64
|
* can't loop the way `npm install -g amicus` could when the rollback recurs.
|
|
65
65
|
*/
|
|
66
66
|
doctorFix: 'amicus doctor --fix (self-heal the Electron GUI in place — provisions the binary; no reinstall, so it can\'t loop)',
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Duplicate legacy 'sidecar' MCP registration (Phase 4 de-bloat): pre-1.8
|
|
70
|
+
* postinstalls registered the same server twice. `doctor --fix` removes the
|
|
71
|
+
* twin only when it points at amicus; a customized entry is never touched.
|
|
72
|
+
*/
|
|
73
|
+
removeLegacySidecar:
|
|
74
|
+
"amicus doctor --fix (removes the duplicate legacy 'sidecar' MCP entry — same server registered twice; the 'amicus' entry stays)",
|
|
67
75
|
});
|
|
68
76
|
|
|
69
77
|
module.exports = REMEDIATION_HINTS;
|
|
@@ -11,6 +11,7 @@ const { getCompatEnv } = require('./env-compat');
|
|
|
11
11
|
const MAX_RESTARTS = 3;
|
|
12
12
|
const RESTART_WINDOW = 5 * 60 * 1000;
|
|
13
13
|
const RESTART_BACKOFF = 2000;
|
|
14
|
+
const CRASH_POLL_INTERVAL = 5000; // ms; env-tunable via AMICUS_CRASH_POLL_MS
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* SharedServerManager - manages a single shared OpenCode server for MCP sessions.
|
|
@@ -45,6 +46,16 @@ class SharedServerManager {
|
|
|
45
46
|
|
|
46
47
|
/** @type {number[]} Timestamps of recent restart attempts */
|
|
47
48
|
this._restartTimestamps = [];
|
|
49
|
+
|
|
50
|
+
/** @type {NodeJS.Timeout|null} goPid liveness poll (H7) */
|
|
51
|
+
this._crashPoll = null;
|
|
52
|
+
|
|
53
|
+
/** @type {NodeJS.Timeout|null} Pending crash-restart backoff timer */
|
|
54
|
+
this._restartTimer = null;
|
|
55
|
+
|
|
56
|
+
/** Pid-liveness probe (test seam). Lazy default keeps construction light. */
|
|
57
|
+
this._isProcessAlive = options.isProcessAlive
|
|
58
|
+
|| ((pid) => require('../sidecar/session-utils').isProcessAlive(pid));
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/**
|
|
@@ -163,6 +174,8 @@ class SharedServerManager {
|
|
|
163
174
|
this._serverWatchdog.cancel();
|
|
164
175
|
this._serverWatchdog = null;
|
|
165
176
|
}
|
|
177
|
+
this._stopCrashPoll();
|
|
178
|
+
if (this._restartTimer) { clearTimeout(this._restartTimer); this._restartTimer = null; }
|
|
166
179
|
if (this.server) {
|
|
167
180
|
this.server.close();
|
|
168
181
|
this.server = null;
|
|
@@ -171,12 +184,10 @@ class SharedServerManager {
|
|
|
171
184
|
}
|
|
172
185
|
|
|
173
186
|
/**
|
|
174
|
-
* Wire crash detection onto a freshly started server handle
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* emitter, guarding against double-wiring across restarts.
|
|
179
|
-
*
|
|
187
|
+
* Wire crash detection onto a freshly started server handle. The REAL handle
|
|
188
|
+
* from buildServerHandle is { url, goPid, close } — it surfaces NO lifecycle
|
|
189
|
+
* events, so the emitter path alone was dead code (H7). Detection now polls
|
|
190
|
+
* the Go engine pid; emitter wiring is kept for handles that do expose events.
|
|
180
191
|
* @param {object} server - Server handle returned by _doStartServer
|
|
181
192
|
*/
|
|
182
193
|
_wireCrashListener(server) {
|
|
@@ -185,19 +196,38 @@ class SharedServerManager {
|
|
|
185
196
|
: (server && server.process && typeof server.process.on === 'function')
|
|
186
197
|
? server.process
|
|
187
198
|
: null;
|
|
188
|
-
if (
|
|
189
|
-
|
|
199
|
+
if (emitter && !emitter._amicusCrashWired) {
|
|
200
|
+
emitter._amicusCrashWired = true;
|
|
201
|
+
const onExit = (code) => {
|
|
202
|
+
if (this.server !== server) { return; } // stale handle already replaced/closed
|
|
203
|
+
this._onServerCrash(code);
|
|
204
|
+
};
|
|
205
|
+
emitter.on('exit', onExit);
|
|
206
|
+
emitter.on('close', onExit);
|
|
207
|
+
}
|
|
208
|
+
if (server && server.goPid) {
|
|
209
|
+
this._startCrashPoll(server);
|
|
210
|
+
} else if (!emitter) {
|
|
211
|
+
this.logger.debug?.('Server handle has no goPid and no emitter — crash detection unavailable');
|
|
190
212
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Poll the Go engine pid; pid death IS the crash signal (H7). */
|
|
216
|
+
_startCrashPoll(server) {
|
|
217
|
+
this._stopCrashPoll();
|
|
218
|
+
const interval = Number(getCompatEnv('CRASH_POLL_MS')) || CRASH_POLL_INTERVAL;
|
|
219
|
+
this._crashPoll = setInterval(() => {
|
|
220
|
+
if (this.server !== server) { this._stopCrashPoll(); return; }
|
|
221
|
+
if (!this._isProcessAlive(server.goPid)) {
|
|
222
|
+
this._stopCrashPoll();
|
|
223
|
+
this._onServerCrash(null);
|
|
196
224
|
}
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
225
|
+
}, interval);
|
|
226
|
+
if (this._crashPoll.unref) { this._crashPoll.unref(); }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_stopCrashPoll() {
|
|
230
|
+
if (this._crashPoll) { clearInterval(this._crashPoll); this._crashPoll = null; }
|
|
201
231
|
}
|
|
202
232
|
|
|
203
233
|
/**
|
|
@@ -206,13 +236,15 @@ class SharedServerManager {
|
|
|
206
236
|
* @param {number} exitCode - Process exit code from the crashed server
|
|
207
237
|
*/
|
|
208
238
|
_onServerCrash(exitCode) {
|
|
239
|
+
this._stopCrashPoll();
|
|
209
240
|
this.logger.error?.('Shared server crashed', { exitCode });
|
|
210
241
|
for (const [id] of this._sessionWatchdogs) {
|
|
211
242
|
this.logger.warn?.('Session interrupted by server crash', { sessionId: id });
|
|
212
243
|
}
|
|
213
244
|
this.server = null;
|
|
214
245
|
this.client = null;
|
|
215
|
-
setTimeout(() => this._handleRestart(), RESTART_BACKOFF);
|
|
246
|
+
this._restartTimer = setTimeout(() => this._handleRestart(), RESTART_BACKOFF);
|
|
247
|
+
if (this._restartTimer.unref) { this._restartTimer.unref(); }
|
|
216
248
|
}
|
|
217
249
|
|
|
218
250
|
/**
|