@mmerterden/multi-agent-toolkit-mcp 3.7.1 → 3.11.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/CHANGELOG.md +275 -0
- package/README.md +86 -2
- package/README.tr.md +13 -3
- package/index.js +345 -87
- package/package.json +3 -3
- package/tools/code-intel/index.js +665 -0
- package/tools/code-intel/kotlin.js +159 -0
- package/tools/code-intel/lsp-client.js +422 -0
- package/tools/code-intel/pool.js +273 -0
- package/tools/code-intel/positions.js +195 -0
- package/tools/code-intel/swift.js +249 -0
- package/tools/design-check/index.js +3 -2
- package/tools/design-check/report.js +15 -5
- package/tools/ios-app-store-audit/index.js +16 -2
- package/tools/ios-testflight/index.js +54 -11
- package/tools/memory/index.js +18 -3
- package/tools/offload/index.js +39 -13
- package/tools/pass-kit/index.js +432 -0
- package/tools/pass-kit/sign.js +255 -0
- package/tools/pass-kit/spec.js +317 -0
- package/tools/pass-kit/validate.js +329 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kotlin.js - JetBrains kotlin-lsp, behind a probe, and labelled as Alpha.
|
|
3
|
+
*
|
|
4
|
+
* WHAT IS VERIFIED AND WHAT IS NOT, stated here because a green test suite would
|
|
5
|
+
* otherwise imply something false. The probe below is verified: on a machine
|
|
6
|
+
* without kotlin-lsp it reports the absence and installs nothing. The SERVER
|
|
7
|
+
* path is not verified by any gate in this repository - kotlin-lsp is not
|
|
8
|
+
* installed here and CI has no JVM - so every Kotlin answer carries
|
|
9
|
+
* `confidence: "alpha"` and the README says the same thing.
|
|
10
|
+
*
|
|
11
|
+
* Upstream state at the time of writing: Alpha, partially closed source, Android
|
|
12
|
+
* Gradle Plugin support experimental. A 4,294-file, 85-module Gradle project is
|
|
13
|
+
* the realistic target here, and importing one of those is the expensive step -
|
|
14
|
+
* hence an initialize budget measured in minutes, not the 20 seconds a Swift
|
|
15
|
+
* request gets.
|
|
16
|
+
*
|
|
17
|
+
* @module tools/code-intel/kotlin
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
23
|
+
|
|
24
|
+
const INSTALL = [
|
|
25
|
+
"brew tap JetBrains/utils",
|
|
26
|
+
"brew install kotlin-lsp",
|
|
27
|
+
"requires a JVM, Java 17 or newer",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const CANDIDATES = [
|
|
31
|
+
"/opt/homebrew/bin/kotlin-lsp",
|
|
32
|
+
"/usr/local/bin/kotlin-lsp",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
let cached;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Is there a Kotlin language server on this machine?
|
|
39
|
+
*
|
|
40
|
+
* Absence is a RESULT, not an error: `code_index_status` exists to be run when
|
|
41
|
+
* something is missing, so it must answer rather than fail. Nothing here spawns
|
|
42
|
+
* the server.
|
|
43
|
+
*/
|
|
44
|
+
export function probeKotlin() {
|
|
45
|
+
if (cached) return cached;
|
|
46
|
+
const bin = findBin();
|
|
47
|
+
if (!bin) {
|
|
48
|
+
return (cached = {
|
|
49
|
+
available: false,
|
|
50
|
+
reason: "kotlin-lsp is not on PATH",
|
|
51
|
+
install: INSTALL,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const java = javaMajor();
|
|
55
|
+
if (java === null) {
|
|
56
|
+
return (cached = {
|
|
57
|
+
available: false,
|
|
58
|
+
bin,
|
|
59
|
+
reason: "kotlin-lsp is installed but no JVM was found",
|
|
60
|
+
install: INSTALL,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (java < 17) {
|
|
64
|
+
return (cached = {
|
|
65
|
+
available: false,
|
|
66
|
+
bin,
|
|
67
|
+
javaVersion: java,
|
|
68
|
+
reason: `kotlin-lsp needs Java 17 or newer, found ${java}`,
|
|
69
|
+
install: INSTALL,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return (cached = { available: true, bin, javaVersion: java, confidence: "alpha" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resetKotlinProbeCache() {
|
|
76
|
+
cached = undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findBin() {
|
|
80
|
+
const explicit = process.env.KOTLIN_LSP_PATH;
|
|
81
|
+
if (explicit && existsSync(explicit)) return explicit;
|
|
82
|
+
try {
|
|
83
|
+
const p = execFileSync("/usr/bin/which", ["kotlin-lsp"], {
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
86
|
+
timeout: 5000,
|
|
87
|
+
}).trim();
|
|
88
|
+
if (p && existsSync(p)) return p;
|
|
89
|
+
} catch {
|
|
90
|
+
/* not on PATH */
|
|
91
|
+
}
|
|
92
|
+
return CANDIDATES.find(existsSync) || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The JVM's major version, or null.
|
|
97
|
+
*
|
|
98
|
+
* `java -version` writes to STDERR and exits 0. execFileSync returns stdout, so
|
|
99
|
+
* reading it there captured an empty string, matched nothing, and reported "no
|
|
100
|
+
* JVM was found" on a machine running Java 17 - and the catch that reads
|
|
101
|
+
* e.stderr never ran, because there was no error. spawnSync is used to get both
|
|
102
|
+
* streams; `--version` (stdout, JDK 9+) is deliberately not used instead,
|
|
103
|
+
* because on a JDK 8 it fails and the answer needed there is "8", not nothing.
|
|
104
|
+
*/
|
|
105
|
+
function javaMajor() {
|
|
106
|
+
const r = spawnSync("java", ["-version"], { encoding: "utf8", timeout: 10000 });
|
|
107
|
+
const out = `${r.stderr || ""}${r.stdout || ""}`;
|
|
108
|
+
const m = /version "(\d+)(?:\.(\d+))?/.exec(out);
|
|
109
|
+
if (!m) return null;
|
|
110
|
+
const major = Number(m[1]);
|
|
111
|
+
// 1.8 style for anything before 9.
|
|
112
|
+
return major === 1 ? Number(m[2] || 0) : major;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The Gradle root at or above a file. */
|
|
116
|
+
export function resolveKotlinRoot(file, explicitRoot) {
|
|
117
|
+
if (explicitRoot) return { root: explicitRoot, source: "explicit" };
|
|
118
|
+
let dir = dirname(file);
|
|
119
|
+
for (let i = 0; i < 40 && dir && dir !== "/"; i++) {
|
|
120
|
+
if (existsSync(join(dir, "settings.gradle.kts")) || existsSync(join(dir, "settings.gradle"))) {
|
|
121
|
+
return { root: dir, source: "gradle" };
|
|
122
|
+
}
|
|
123
|
+
dir = dirname(dir);
|
|
124
|
+
}
|
|
125
|
+
dir = dirname(file);
|
|
126
|
+
for (let i = 0; i < 40 && dir && dir !== "/"; i++) {
|
|
127
|
+
if (existsSync(join(dir, "build.gradle.kts")) || existsSync(join(dir, "build.gradle"))) {
|
|
128
|
+
return { root: dir, source: "gradle-module" };
|
|
129
|
+
}
|
|
130
|
+
dir = dirname(dir);
|
|
131
|
+
}
|
|
132
|
+
return { root: dirname(file), source: "fallback" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Kotlin's half of `code_index_status`, whether or not anything is installed. */
|
|
136
|
+
export function kotlinReport(root) {
|
|
137
|
+
const probe = probeKotlin();
|
|
138
|
+
if (!probe.available) {
|
|
139
|
+
return {
|
|
140
|
+
available: false,
|
|
141
|
+
root,
|
|
142
|
+
reason: probe.reason,
|
|
143
|
+
install: probe.install,
|
|
144
|
+
note: "Kotlin support here is written against JetBrains kotlin-lsp (Alpha) and is not exercised by any gate in this repository.",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
available: true,
|
|
149
|
+
root,
|
|
150
|
+
bin: probe.bin,
|
|
151
|
+
javaVersion: probe.javaVersion,
|
|
152
|
+
confidence: "alpha",
|
|
153
|
+
note: "kotlin-lsp is Alpha and its Android Gradle Plugin support is experimental. The first request against a multi-module Gradle build triggers an import that can take minutes.",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const KOTLIN_INIT_TIMEOUT_MS = 300000;
|
|
158
|
+
export const KOTLIN_ARGV = ["--stdio"];
|
|
159
|
+
export { INSTALL as KOTLIN_INSTALL };
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lsp-client.js - a JSON-RPC client for one language server, over its stdio.
|
|
3
|
+
*
|
|
4
|
+
* STDOUT DISCIPLINE, first because it is the one mistake that breaks everything
|
|
5
|
+
* else. This process speaks JSON-RPC to its own MCP host on fd 1. A language
|
|
6
|
+
* server speaks JSON-RPC too, and getting the two streams confused corrupts the
|
|
7
|
+
* host's channel and takes every tool in the server down with it, not just this
|
|
8
|
+
* family. Three independent rules, all applied:
|
|
9
|
+
*
|
|
10
|
+
* 1. The child is spawned with `stdio: ["pipe","pipe","pipe"]`. Never
|
|
11
|
+
* "inherit" for fd 1 or 2. Nothing in this file touches process.stdout.
|
|
12
|
+
* 2. The child's stderr is drained into a small ring buffer and never
|
|
13
|
+
* forwarded. sourcekit-lsp logs there when SOURCEKIT_LSP_LOG_LEVEL is set,
|
|
14
|
+
* so the spawn env deletes that rather than inheriting a developer's shell.
|
|
15
|
+
* 3. Bytes off the child's stdout are parsed as LSP frames and never
|
|
16
|
+
* re-emitted. Anything unparseable goes into the same ring buffer.
|
|
17
|
+
*
|
|
18
|
+
* FRAMING is done over a Buffer, not a string. `Content-Length` counts bytes,
|
|
19
|
+
* and a UTF-8 identifier - a Turkish `ö`, an emoji in a string literal - will
|
|
20
|
+
* straddle a chunk boundary and be miscounted the moment the accumulator is a
|
|
21
|
+
* string.
|
|
22
|
+
*
|
|
23
|
+
* INDEXING is tracked, because it is the difference between two answers that
|
|
24
|
+
* look identical. Measured against sourcekit-lsp 6.3.1: a `references` request
|
|
25
|
+
* answers `[]` while the background index is still building, and `[3]` sixteen
|
|
26
|
+
* seconds later with no other change. Returning the first as "no references"
|
|
27
|
+
* reads as "this symbol is unused". The server announces the work - a
|
|
28
|
+
* `window/workDoneProgress/create` for a token named `indexing.<uuid>`, then
|
|
29
|
+
* `$/progress` begin ("Indexing", "Determining files"), report ("0 / 2"), end -
|
|
30
|
+
* so `indexing()` reports whether any such token is currently open, and
|
|
31
|
+
* `waitForIndex()` lets a caller hold until it closes.
|
|
32
|
+
*
|
|
33
|
+
* @module tools/code-intel/lsp-client
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { spawn } from "node:child_process";
|
|
37
|
+
|
|
38
|
+
const STDERR_RING = 8 * 1024;
|
|
39
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
|
|
40
|
+
|
|
41
|
+
/** Env a language server should not inherit from whoever started the host. */
|
|
42
|
+
const SCRUBBED_ENV = [
|
|
43
|
+
"SOURCEKIT_LSP_LOG_LEVEL",
|
|
44
|
+
"SOURCEKIT_LSP_LOG_PRIVACY_LEVEL",
|
|
45
|
+
"SOURCEKIT_TOOLCHAIN_PATH",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Start a language server and talk to it.
|
|
50
|
+
*
|
|
51
|
+
* Never throws for anything the server does; a failure is a rejected request
|
|
52
|
+
* with a readable reason, and a dead server is reported through `onCrash`.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* @param {string} opts.bin executable
|
|
56
|
+
* @param {string[]} [opts.argv] arguments, as an array - no shell
|
|
57
|
+
* @param {string} opts.cwd workspace root
|
|
58
|
+
* @param {object} [opts.env] extra env
|
|
59
|
+
* @param {object} opts.initializeParams LSP initialize params
|
|
60
|
+
* @param {number} [opts.requestTimeoutMs]
|
|
61
|
+
* @param {(m: object) => void} [opts.onNotification]
|
|
62
|
+
* @param {(info: object) => void} [opts.onCrash]
|
|
63
|
+
* @returns {object} the client
|
|
64
|
+
*/
|
|
65
|
+
export function createLspClient(opts) {
|
|
66
|
+
const {
|
|
67
|
+
bin,
|
|
68
|
+
argv = [],
|
|
69
|
+
cwd,
|
|
70
|
+
env = {},
|
|
71
|
+
initializeParams,
|
|
72
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
73
|
+
onNotification,
|
|
74
|
+
onCrash,
|
|
75
|
+
} = opts;
|
|
76
|
+
|
|
77
|
+
const childEnv = { ...process.env, ...env };
|
|
78
|
+
for (const k of SCRUBBED_ENV) delete childEnv[k];
|
|
79
|
+
|
|
80
|
+
const child = spawn(bin, argv, { cwd, env: childEnv, stdio: ["pipe", "pipe", "pipe"] });
|
|
81
|
+
|
|
82
|
+
let buf = Buffer.alloc(0);
|
|
83
|
+
let stderrTail = "";
|
|
84
|
+
let nextId = 1;
|
|
85
|
+
let disposing = false;
|
|
86
|
+
let exited = null;
|
|
87
|
+
let serverCapabilities = null;
|
|
88
|
+
const pending = new Map();
|
|
89
|
+
/** Extra notification subscribers, added and removed per request. */
|
|
90
|
+
const subscribers = new Set();
|
|
91
|
+
/** Open work-done tokens, by token -> {title, message, percentage}. */
|
|
92
|
+
const progress = new Map();
|
|
93
|
+
const indexWaiters = [];
|
|
94
|
+
|
|
95
|
+
const note = (s) => {
|
|
96
|
+
stderrTail = (stderrTail + s).slice(-STDERR_RING);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
child.stderr.on("data", (d) => note(String(d)));
|
|
100
|
+
child.on("error", (e) => finish(127, e.message));
|
|
101
|
+
child.on("exit", (code, signal) => finish(code ?? -1, signal ? `signal ${signal}` : ""));
|
|
102
|
+
|
|
103
|
+
function finish(code, detail) {
|
|
104
|
+
if (exited) return;
|
|
105
|
+
exited = { code, detail };
|
|
106
|
+
const reason =
|
|
107
|
+
`ERROR: ${bin} exited (${code})` + (detail ? ` ${detail}` : "") + tail();
|
|
108
|
+
for (const [, p] of pending) {
|
|
109
|
+
clearTimeout(p.timer);
|
|
110
|
+
p.resolve({ error: { message: reason } });
|
|
111
|
+
}
|
|
112
|
+
pending.clear();
|
|
113
|
+
releaseIndexWaiters();
|
|
114
|
+
if (!disposing && typeof onCrash === "function") {
|
|
115
|
+
onCrash({ code, detail, stderr: stderrTail });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function tail() {
|
|
120
|
+
const t = stderrTail.trim();
|
|
121
|
+
return t ? `: ${t.slice(-400)}` : "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function write(obj) {
|
|
125
|
+
if (exited || !child.stdin.writable) return false;
|
|
126
|
+
const s = JSON.stringify(obj);
|
|
127
|
+
child.stdin.write(`Content-Length: ${Buffer.byteLength(s, "utf8")}\r\n\r\n${s}`);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
child.stdout.on("data", (chunk) => {
|
|
132
|
+
buf = Buffer.concat([buf, chunk]);
|
|
133
|
+
for (;;) {
|
|
134
|
+
const sep = buf.indexOf("\r\n\r\n");
|
|
135
|
+
if (sep === -1) {
|
|
136
|
+
// A header this long is not a header. Drop it rather than growing
|
|
137
|
+
// without bound on a server that prints a banner and nothing else.
|
|
138
|
+
if (buf.length > 64 * 1024) {
|
|
139
|
+
note(buf.toString("utf8"));
|
|
140
|
+
buf = Buffer.alloc(0);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const header = buf.slice(0, sep).toString("ascii");
|
|
145
|
+
const m = /content-length:\s*(\d+)/i.exec(header);
|
|
146
|
+
if (!m) {
|
|
147
|
+
note(header);
|
|
148
|
+
buf = buf.slice(sep + 4);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const len = Number(m[1]);
|
|
152
|
+
if (buf.length < sep + 4 + len) return;
|
|
153
|
+
const body = buf.slice(sep + 4, sep + 4 + len).toString("utf8");
|
|
154
|
+
buf = buf.slice(sep + 4 + len);
|
|
155
|
+
let msg;
|
|
156
|
+
try {
|
|
157
|
+
msg = JSON.parse(body);
|
|
158
|
+
} catch {
|
|
159
|
+
note(body);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
dispatch(msg);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
function dispatch(msg) {
|
|
167
|
+
// A server-to-client REQUEST has both an id and a method. Ignoring these
|
|
168
|
+
// hangs the server: sourcekit-lsp waits on workDoneProgress/create before
|
|
169
|
+
// it starts indexing.
|
|
170
|
+
if (msg.id !== undefined && msg.method) {
|
|
171
|
+
if (msg.method === "window/workDoneProgress/create") {
|
|
172
|
+
const token = msg.params?.token;
|
|
173
|
+
if (token !== undefined) progress.set(tokenKey(token), { title: null });
|
|
174
|
+
}
|
|
175
|
+
write({ jsonrpc: "2.0", id: msg.id, result: null });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
179
|
+
const p = pending.get(msg.id);
|
|
180
|
+
pending.delete(msg.id);
|
|
181
|
+
clearTimeout(p.timer);
|
|
182
|
+
p.resolve(msg);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (msg.method === "$/progress") {
|
|
186
|
+
onProgress(msg.params);
|
|
187
|
+
fanout(msg);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (msg.method) {
|
|
191
|
+
fanout(msg);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
note(JSON.stringify(msg).slice(0, 500));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function fanout(msg) {
|
|
198
|
+
if (typeof onNotification === "function") onNotification(msg);
|
|
199
|
+
for (const s of subscribers) {
|
|
200
|
+
try {
|
|
201
|
+
s(msg);
|
|
202
|
+
} catch {
|
|
203
|
+
// A subscriber that throws must not take the read loop with it.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Listen to notifications for the length of one request.
|
|
210
|
+
*
|
|
211
|
+
* Diagnostics are the reason this exists: sourcekit-lsp publishes them
|
|
212
|
+
* unsolicited after didOpen rather than answering a request, so a caller has
|
|
213
|
+
* to be listening before it asks.
|
|
214
|
+
*
|
|
215
|
+
* @returns {() => void} unsubscribe
|
|
216
|
+
*/
|
|
217
|
+
function subscribe(handler) {
|
|
218
|
+
subscribers.add(handler);
|
|
219
|
+
return () => subscribers.delete(handler);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function tokenKey(t) {
|
|
223
|
+
return typeof t === "string" ? t : JSON.stringify(t);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onProgress(params) {
|
|
227
|
+
if (!params) return;
|
|
228
|
+
const key = tokenKey(params.token);
|
|
229
|
+
const v = params.value || {};
|
|
230
|
+
if (v.kind === "end") {
|
|
231
|
+
progress.delete(key);
|
|
232
|
+
releaseIndexWaiters();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const prev = progress.get(key) || {};
|
|
236
|
+
progress.set(key, {
|
|
237
|
+
title: v.title ?? prev.title ?? null,
|
|
238
|
+
message: v.message ?? prev.message ?? null,
|
|
239
|
+
percentage: v.percentage ?? prev.percentage ?? null,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** An open token that is the index build, by either of the two signals. */
|
|
244
|
+
function indexingEntry() {
|
|
245
|
+
for (const [key, v] of progress) {
|
|
246
|
+
if (key.startsWith("indexing.")) return { key, ...v };
|
|
247
|
+
if (typeof v.title === "string" && /index/i.test(v.title)) return { key, ...v };
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function releaseIndexWaiters() {
|
|
253
|
+
if (indexingEntry()) return;
|
|
254
|
+
while (indexWaiters.length) {
|
|
255
|
+
const w = indexWaiters.shift();
|
|
256
|
+
clearTimeout(w.timer);
|
|
257
|
+
w.resolve(true);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Send a request. Resolves with the raw JSON-RPC message, or a synthetic
|
|
263
|
+
* `{error}` - it never rejects, so no caller needs a try/catch to stay alive.
|
|
264
|
+
*/
|
|
265
|
+
function request(method, params, { timeoutMs = requestTimeoutMs, signal } = {}) {
|
|
266
|
+
if (exited) {
|
|
267
|
+
return Promise.resolve({
|
|
268
|
+
error: { message: `ERROR: ${bin} is not running (${exited.code})${tail()}` },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const id = nextId++;
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
let onAbort;
|
|
274
|
+
const settle = (v) => {
|
|
275
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
276
|
+
resolve(v);
|
|
277
|
+
};
|
|
278
|
+
const timer = setTimeout(() => {
|
|
279
|
+
pending.delete(id);
|
|
280
|
+
write({ jsonrpc: "2.0", method: "$/cancelRequest", params: { id } });
|
|
281
|
+
// The server is NOT killed. One slow query is not a reason to throw
|
|
282
|
+
// away a warm index that took minutes to build.
|
|
283
|
+
settle({ error: { message: `ERROR: ${method} timed out after ${timeoutMs}ms` } });
|
|
284
|
+
}, timeoutMs);
|
|
285
|
+
pending.set(id, { resolve: settle, timer, method });
|
|
286
|
+
if (signal) {
|
|
287
|
+
onAbort = () => {
|
|
288
|
+
const p = pending.get(id);
|
|
289
|
+
if (!p) return;
|
|
290
|
+
pending.delete(id);
|
|
291
|
+
clearTimeout(p.timer);
|
|
292
|
+
write({ jsonrpc: "2.0", method: "$/cancelRequest", params: { id } });
|
|
293
|
+
settle({ error: { message: `ERROR: ${method} aborted` } });
|
|
294
|
+
};
|
|
295
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
296
|
+
}
|
|
297
|
+
if (!write({ jsonrpc: "2.0", id, method, params })) {
|
|
298
|
+
pending.delete(id);
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
settle({ error: { message: `ERROR: cannot write to ${bin}` } });
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function notify(method, params) {
|
|
306
|
+
write({ jsonrpc: "2.0", method, params });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function initialize({ timeoutMs } = {}) {
|
|
310
|
+
const res = await request("initialize", initializeParams, { timeoutMs });
|
|
311
|
+
if (res.error) return res;
|
|
312
|
+
serverCapabilities = res.result?.capabilities || {};
|
|
313
|
+
notify("initialized", {});
|
|
314
|
+
return res;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Hold until the index build closes, or the budget runs out.
|
|
319
|
+
*
|
|
320
|
+
* `settleMs` is not a nicety. Measured: `initialize` answers at ~680ms and the
|
|
321
|
+
* `indexing.<uuid>` token does not open until ~1750ms, so a wait that only
|
|
322
|
+
* asked "is a token open right now" returned true 900ms too early and the
|
|
323
|
+
* caller went on to read an empty `references` as "no references". The wait
|
|
324
|
+
* therefore has to survive a quiet gap: no open token has to STAY true for
|
|
325
|
+
* `settleMs` before it counts as done.
|
|
326
|
+
*
|
|
327
|
+
* @returns {Promise<boolean>} true when idle, false when the budget ran out
|
|
328
|
+
*/
|
|
329
|
+
async function waitForIndex(timeoutMs, { settleMs = 2500 } = {}) {
|
|
330
|
+
const deadline = Date.now() + timeoutMs;
|
|
331
|
+
for (;;) {
|
|
332
|
+
if (indexingEntry()) {
|
|
333
|
+
const left = deadline - Date.now();
|
|
334
|
+
if (left <= 0) return false;
|
|
335
|
+
const closed = await awaitIndexClose(left);
|
|
336
|
+
if (!closed) return false;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const quiet = Math.min(settleMs, Math.max(0, deadline - Date.now()));
|
|
340
|
+
if (quiet === 0) return !indexingEntry();
|
|
341
|
+
await delay(quiet, { keepAlive: true });
|
|
342
|
+
if (!indexingEntry()) return true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function awaitIndexClose(timeoutMs) {
|
|
347
|
+
return new Promise((resolve) => {
|
|
348
|
+
const timer = setTimeout(() => {
|
|
349
|
+
const i = indexWaiters.findIndex((w) => w.timer === timer);
|
|
350
|
+
if (i >= 0) indexWaiters.splice(i, 1);
|
|
351
|
+
resolve(false);
|
|
352
|
+
}, timeoutMs);
|
|
353
|
+
indexWaiters.push({ resolve, timer });
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function dispose() {
|
|
358
|
+
disposing = true;
|
|
359
|
+
if (!exited) {
|
|
360
|
+
await Promise.race([
|
|
361
|
+
request("shutdown", null, { timeoutMs: 2000 }),
|
|
362
|
+
delay(2000, { keepAlive: true }),
|
|
363
|
+
]);
|
|
364
|
+
notify("exit");
|
|
365
|
+
await delay(150, { keepAlive: true });
|
|
366
|
+
if (!exited) child.kill("SIGTERM");
|
|
367
|
+
const hard = setTimeout(() => {
|
|
368
|
+
if (!exited) child.kill("SIGKILL");
|
|
369
|
+
}, 1000);
|
|
370
|
+
hard.unref?.();
|
|
371
|
+
}
|
|
372
|
+
// SETTLE, not just clear. Clearing a pending entry drops its `resolve`
|
|
373
|
+
// with nobody left to call it: the exit event that would have run finish()
|
|
374
|
+
// arrives to an empty map, and whoever was awaiting that request waits for
|
|
375
|
+
// the rest of the process's life. Measured against a server that ignores
|
|
376
|
+
// shutdown - the request never settled and the probe had to be timed out.
|
|
377
|
+
for (const [, p] of pending) {
|
|
378
|
+
clearTimeout(p.timer);
|
|
379
|
+
p.resolve({ error: { message: `ERROR: ${bin} was shut down while ${p.method} was in flight` } });
|
|
380
|
+
}
|
|
381
|
+
pending.clear();
|
|
382
|
+
while (indexWaiters.length) {
|
|
383
|
+
const w = indexWaiters.shift();
|
|
384
|
+
clearTimeout(w.timer);
|
|
385
|
+
w.resolve(false);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
request,
|
|
391
|
+
notify,
|
|
392
|
+
subscribe,
|
|
393
|
+
initialize,
|
|
394
|
+
dispose,
|
|
395
|
+
waitForIndex,
|
|
396
|
+
get pid() {
|
|
397
|
+
return child.pid;
|
|
398
|
+
},
|
|
399
|
+
get alive() {
|
|
400
|
+
return !exited;
|
|
401
|
+
},
|
|
402
|
+
get capabilities() {
|
|
403
|
+
return serverCapabilities;
|
|
404
|
+
},
|
|
405
|
+
/** `{title, message, percentage}` while the index is building, else null. */
|
|
406
|
+
indexing: () => indexingEntry(),
|
|
407
|
+
stderrTail: () => stderrTail,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* `keepAlive` exists because an unref'd timer that is the only thing left in the
|
|
413
|
+
* loop never fires, and an `await` on it hangs the process instead of ending it.
|
|
414
|
+
* Measured on the first dispose(): "Detected unsettled top-level await". Waits
|
|
415
|
+
* that something is awaiting must hold the loop; fire-and-forget ones must not.
|
|
416
|
+
*/
|
|
417
|
+
function delay(ms, { keepAlive = false } = {}) {
|
|
418
|
+
return new Promise((r) => {
|
|
419
|
+
const t = setTimeout(r, ms);
|
|
420
|
+
if (!keepAlive) t.unref?.();
|
|
421
|
+
});
|
|
422
|
+
}
|