@banbolee/dsh-lsp-diagnostics 0.1.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/README.md +297 -0
- package/collector.js +220 -0
- package/coordinator.js +686 -0
- package/cordis.patch.yml +4 -0
- package/framing.js +170 -0
- package/index.js +581 -0
- package/package.json +70 -0
- package/render.js +468 -0
- package/runtime.js +1613 -0
- package/tool.js +496 -0
package/runtime.js
ADDED
|
@@ -0,0 +1,1613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics runtime: pooled per-(provider, workspace) LSP sessions over
|
|
3
|
+
* subprocesses.
|
|
4
|
+
*
|
|
5
|
+
* Owns the two-level pool (`Map<providerId, Map<workspaceTargetKey, Session>>`
|
|
6
|
+
* and the same-shaped serialization tails), the complete JSON-RPC state
|
|
7
|
+
* machine, bounded document reads, exact-URI-first version correlation, strict
|
|
8
|
+
* consumed-field Diagnostic projection, transport eviction with a single
|
|
9
|
+
* same-call restart, generic fresh-session rotation before a same-URI reopen,
|
|
10
|
+
* and the unique graceful-first session teardown. The
|
|
11
|
+
* runtime never creates or extends the execution deadline — it only consumes
|
|
12
|
+
* the caller-provided operation signal (the coordinator relays caller abort,
|
|
13
|
+
* its absolute deadline, and cleanup abort into it).
|
|
14
|
+
*
|
|
15
|
+
* @module @banbolee/dsh-lsp-diagnostics/runtime
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { FsError } from '@deepseek-ai/dsh-fs'
|
|
19
|
+
import { encodeMessage, MessageDecoder } from './framing.js'
|
|
20
|
+
|
|
21
|
+
/** Server→client request methods answered with an empty result (no dynamic registration). */
|
|
22
|
+
const LIFECYCLE_NOOP_METHODS = new Set([
|
|
23
|
+
'window/workDoneProgress/create',
|
|
24
|
+
'client/registerCapability',
|
|
25
|
+
'client/unregisterCapability',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
/** Client capabilities advertised at `initialize`. */
|
|
29
|
+
const CLIENT_CAPABILITIES = {
|
|
30
|
+
general: { positionEncodings: ['utf-16'] },
|
|
31
|
+
workspace: { workspaceFolders: true, configuration: true },
|
|
32
|
+
textDocument: {
|
|
33
|
+
synchronization: { dynamicRegistration: false },
|
|
34
|
+
publishDiagnostics: { versionSupport: true },
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @type {Readonly<Record<number, 'error' | 'warning' | 'info' | 'hint'>>} */
|
|
39
|
+
const SEVERITY_TO_TOKEN = { 1: 'error', 2: 'warning', 3: 'info', 4: 'hint' }
|
|
40
|
+
|
|
41
|
+
/** @type {Readonly<Record<string, 0 | 1 | 2 | 3 | 4>>} */
|
|
42
|
+
const SEVERITY_RANK = { error: 0, warning: 1, info: 2, hint: 3, unknown: 4 }
|
|
43
|
+
|
|
44
|
+
/** @typedef {'error' | 'warning' | 'info' | 'hint' | 'unknown'} SeverityToken */
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} NormalizedPosition
|
|
48
|
+
* @property {number} line - LSP 0-based UTF-16 line.
|
|
49
|
+
* @property {number} character - LSP 0-based UTF-16 character.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {object} NormalizedRange
|
|
54
|
+
* @property {NormalizedPosition} start
|
|
55
|
+
* @property {NormalizedPosition} end
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {object} NormalizedDiagnostic
|
|
60
|
+
* @property {string} uri
|
|
61
|
+
* @property {NormalizedRange} range
|
|
62
|
+
* @property {SeverityToken} severity
|
|
63
|
+
* @property {0|1|2|3|4} severityRank
|
|
64
|
+
* @property {string} code
|
|
65
|
+
* @property {string} source
|
|
66
|
+
* @property {string} message
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {'server not found' | 'server crashed' | 'timeout' | 'malformed response' | 'document too large' | 'diagnostics unavailable'} UnavailableReason
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {{ kind: 'ok', diagnostics: NormalizedDiagnostic[], uri: string, version: number } |
|
|
75
|
+
* { kind: 'stale' } |
|
|
76
|
+
* { kind: 'unavailable', reason: UnavailableReason }} DiagnosisOutcome
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @typedef {object} ServerConfig
|
|
81
|
+
* @property {string} command
|
|
82
|
+
* @property {readonly string[]} args
|
|
83
|
+
* @property {Record<string, string>} env
|
|
84
|
+
* @property {unknown} configuration
|
|
85
|
+
* @property {unknown} initializationOptions
|
|
86
|
+
* @property {Record<string, string>} extensionToLanguage
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {object} RuntimeConfig
|
|
91
|
+
* @property {number} settleMs
|
|
92
|
+
* @property {number} shutdownTimeoutMs
|
|
93
|
+
* @property {number} killGraceMs
|
|
94
|
+
* @property {number} maxDocumentBytes
|
|
95
|
+
* @property {number} maxMessageBytes
|
|
96
|
+
* @property {number} maxStderrBytes
|
|
97
|
+
* @property {Record<string, ServerConfig>} servers
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @typedef {object} SessionHandle
|
|
102
|
+
* @property {number} pid
|
|
103
|
+
* @property {{ write(chunk: Buffer, callback?: (error?: Error | null) => void): void, on(event: 'error', listener: (error: Error) => void): void, removeListener?(event: 'error', listener: (error: Error) => void): void }} stdin
|
|
104
|
+
* @property {import('node:events').EventEmitter} stdout
|
|
105
|
+
* @property {import('node:events').EventEmitter} stderr
|
|
106
|
+
* @property {Record<string, never>} collected
|
|
107
|
+
* @property {Promise<{ exitCode: number | null, signal: NodeJS.Signals | null }>} done
|
|
108
|
+
* @property {() => void} terminate
|
|
109
|
+
* @property {(signal?: AbortSignal) => Promise<boolean>} waitForExit
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @typedef {object} SubprocessSeam
|
|
114
|
+
* @property {(command: string, env: Record<string, string>, signal?: AbortSignal) => Promise<string>} resolveExecutable
|
|
115
|
+
* @property {(spec: object) => SessionHandle} spawn
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @typedef {object} FsSeam
|
|
120
|
+
* @property {(target: unknown, signal?: AbortSignal) => Promise<{ version: string, type: string, size?: number } | undefined>} stat
|
|
121
|
+
* @property {(target: unknown, signal: AbortSignal | undefined, maxBytes: number) => Promise<Uint8Array>} readBytes
|
|
122
|
+
* @property {(target: unknown) => string} processPath
|
|
123
|
+
* @property {(target: unknown) => string} fileUrl
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @typedef {object} Waiter
|
|
128
|
+
* @property {string} canonicalUri - the exact canonical document URI.
|
|
129
|
+
* @property {number} currentVersion - the didOpen version of this open generation.
|
|
130
|
+
* @property {boolean} firstOpen - whether this process opens this URI for the first time.
|
|
131
|
+
* @property {boolean} opened - whether the didOpen write succeeded; only a
|
|
132
|
+
* successfully written open generation may accept publications.
|
|
133
|
+
* @property {NormalizedDiagnostic[] | null} preOpenBatch - publications that
|
|
134
|
+
* arrived while the didOpen write was still in flight, buffered until the
|
|
135
|
+
* open succeeds; they never set `acceptedPublication` by themselves.
|
|
136
|
+
* @property {boolean} settled - the waiter is done (accepted, timed out, aborted, or failed).
|
|
137
|
+
* @property {NormalizedDiagnostic[] | null} batch - the latest accepted batch.
|
|
138
|
+
* @property {ReturnType<typeof setTimeout> | undefined} timer - the quiet-window timer.
|
|
139
|
+
* @property {Promise<DiagnosisOutcome>} promise
|
|
140
|
+
* @property {(value: DiagnosisOutcome) => void} resolve
|
|
141
|
+
* @property {(reason: Error) => void} reject
|
|
142
|
+
* @property {AbortSignal | undefined} signal
|
|
143
|
+
* @property {(() => void) | undefined} onAbort
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A fatal transport or protocol failure with its closed unavailable reason.
|
|
148
|
+
* @private
|
|
149
|
+
*/
|
|
150
|
+
class TransportError extends Error {
|
|
151
|
+
/**
|
|
152
|
+
* @param {UnavailableReason} reason - the closed reason surfaced to the coordinator.
|
|
153
|
+
* @param {unknown} [cause] - the underlying error.
|
|
154
|
+
*/
|
|
155
|
+
constructor(reason, cause) {
|
|
156
|
+
super(`lsp transport failure: ${reason}`, cause === undefined ? undefined : { cause })
|
|
157
|
+
this.name = 'TransportError'
|
|
158
|
+
/** @type {UnavailableReason} */
|
|
159
|
+
this.reason = reason
|
|
160
|
+
/** @type {boolean} - set on stdin write-path failures so bootstrap can
|
|
161
|
+
* distinguish a dead-server EPIPE from a decoder/protocol failure. */
|
|
162
|
+
this.writeFailure = false
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Race a promise against an abort signal.
|
|
168
|
+
* @param {Promise<unknown>} promise - the operation to bound.
|
|
169
|
+
* @param {AbortSignal} [signal] - cancellation.
|
|
170
|
+
* @returns {Promise<unknown>}
|
|
171
|
+
*/
|
|
172
|
+
function abortable(promise, signal) {
|
|
173
|
+
if (signal === undefined) return promise
|
|
174
|
+
if (signal.aborted) return Promise.reject(abortError(signal))
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const onAbort = () => {
|
|
177
|
+
cleanup()
|
|
178
|
+
reject(abortError(signal))
|
|
179
|
+
}
|
|
180
|
+
const cleanup = () => signal.removeEventListener('abort', onAbort)
|
|
181
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
182
|
+
promise.then(
|
|
183
|
+
(value) => {
|
|
184
|
+
cleanup()
|
|
185
|
+
resolve(value)
|
|
186
|
+
},
|
|
187
|
+
(error) => {
|
|
188
|
+
cleanup()
|
|
189
|
+
reject(error)
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Build a consistent abort error for a signal.
|
|
197
|
+
* @param {AbortSignal} [signal] - the aborted signal.
|
|
198
|
+
* @returns {Error}
|
|
199
|
+
*/
|
|
200
|
+
function abortError(signal) {
|
|
201
|
+
const error = new Error('operation aborted')
|
|
202
|
+
error.name = 'AbortError'
|
|
203
|
+
if (signal !== undefined && signal.reason !== undefined) {
|
|
204
|
+
try {
|
|
205
|
+
error.cause = signal.reason
|
|
206
|
+
} catch {
|
|
207
|
+
// reason getter must not break cancellation
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return error
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Lowercase last path suffix of a file URL's pathname (`.ts`, `.tsx`, `.go`).
|
|
215
|
+
* Never reads `displayPath` for routing.
|
|
216
|
+
* @param {string} uri - the canonical document URI.
|
|
217
|
+
* @returns {string} the extension including the dot, or '' when absent.
|
|
218
|
+
*/
|
|
219
|
+
function extensionOf(uri) {
|
|
220
|
+
try {
|
|
221
|
+
const pathname = new URL(uri).pathname
|
|
222
|
+
const slash = pathname.lastIndexOf('/')
|
|
223
|
+
const base = slash === -1 ? pathname : pathname.slice(slash + 1)
|
|
224
|
+
const dot = base.lastIndexOf('.')
|
|
225
|
+
if (dot === -1) return ''
|
|
226
|
+
return base.slice(dot).toLowerCase()
|
|
227
|
+
} catch {
|
|
228
|
+
return ''
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Normalize one consumed-field diagnostic against the strict V1 schema.
|
|
234
|
+
*
|
|
235
|
+
* Only `range`/`severity`/`code`/`source`/`message` are read and validated;
|
|
236
|
+
* standard optional fields (`tags`, `relatedInformation`, `codeDescription`,
|
|
237
|
+
* `data`) and unknown extension fields are never traversed, validated,
|
|
238
|
+
* stringified, or copied, and can never make the publication fatal by
|
|
239
|
+
* themselves. Server strings are sanitized to single-line safe text. The
|
|
240
|
+
* returned object is frozen and matches the strict normalized schema exactly.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} uri - the exact canonical document URI.
|
|
243
|
+
* @param {unknown} raw - one raw LSP Diagnostic.
|
|
244
|
+
* @returns {NormalizedDiagnostic} the immutable normalized diagnostic.
|
|
245
|
+
* @throws {Error} when any consumed field is missing or invalid.
|
|
246
|
+
*/
|
|
247
|
+
export function normalizeDiagnostic(uri, raw) {
|
|
248
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
249
|
+
throw new Error('diagnostic must be an object')
|
|
250
|
+
}
|
|
251
|
+
const message = /** @type {{ message?: unknown }} */ (raw).message
|
|
252
|
+
if (typeof message !== 'string') throw new Error('diagnostic message must be a string')
|
|
253
|
+
const range = normalizeRange(/** @type {{ range?: unknown }} */ (raw).range)
|
|
254
|
+
const severity = normalizeSeverity(/** @type {{ severity?: unknown }} */ (raw).severity)
|
|
255
|
+
const code = normalizeCode(/** @type {{ code?: unknown }} */ (raw).code)
|
|
256
|
+
const source = normalizeSource(/** @type {{ source?: unknown }} */ (raw).source)
|
|
257
|
+
return Object.freeze({
|
|
258
|
+
uri,
|
|
259
|
+
range,
|
|
260
|
+
severity,
|
|
261
|
+
severityRank: /** @type {0|1|2|3|4} */ (SEVERITY_RANK[severity]),
|
|
262
|
+
code: sanitizeServerString(code),
|
|
263
|
+
source: sanitizeServerString(source),
|
|
264
|
+
message: sanitizeServerString(message),
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Validate a diagnostic range (strict object with exactly `start`/`end`).
|
|
270
|
+
* @param {unknown} value - the raw range.
|
|
271
|
+
* @returns {NormalizedRange}
|
|
272
|
+
*/
|
|
273
|
+
function normalizeRange(value) {
|
|
274
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
275
|
+
throw new Error('diagnostic range must be an object')
|
|
276
|
+
}
|
|
277
|
+
const keys = Object.keys(value)
|
|
278
|
+
if (keys.length !== 2 || !('start' in value) || !('end' in value)) {
|
|
279
|
+
throw new Error('diagnostic range must have exactly start and end')
|
|
280
|
+
}
|
|
281
|
+
const start = normalizePosition(/** @type {{ start?: unknown }} */ (value).start)
|
|
282
|
+
const end = normalizePosition(/** @type {{ end?: unknown }} */ (value).end)
|
|
283
|
+
if (end.line < start.line || (end.line === start.line && end.character < start.character)) {
|
|
284
|
+
throw new Error('diagnostic range end must not precede start')
|
|
285
|
+
}
|
|
286
|
+
return Object.freeze({ start, end })
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Validate a 0-based UTF-16 position (strict object with exactly `line`/`character`).
|
|
291
|
+
* @param {unknown} value - the raw position.
|
|
292
|
+
* @returns {NormalizedPosition}
|
|
293
|
+
*/
|
|
294
|
+
function normalizePosition(value) {
|
|
295
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
296
|
+
throw new Error('diagnostic position must be an object')
|
|
297
|
+
}
|
|
298
|
+
const keys = Object.keys(value)
|
|
299
|
+
if (keys.length !== 2 || !('line' in value) || !('character' in value)) {
|
|
300
|
+
throw new Error('diagnostic position must have exactly line and character')
|
|
301
|
+
}
|
|
302
|
+
const line = /** @type {{ line?: unknown }} */ (value).line
|
|
303
|
+
const character = /** @type {{ character?: unknown }} */ (value).character
|
|
304
|
+
if (typeof line !== 'number' || !Number.isSafeInteger(line) || line < 0 || line >= Number.MAX_SAFE_INTEGER) {
|
|
305
|
+
throw new Error('diagnostic position line must be an integer in [0, MAX_SAFE_INTEGER)')
|
|
306
|
+
}
|
|
307
|
+
if (typeof character !== 'number' || !Number.isSafeInteger(character) || character < 0 || character >= Number.MAX_SAFE_INTEGER) {
|
|
308
|
+
throw new Error('diagnostic position character must be an integer in [0, MAX_SAFE_INTEGER)')
|
|
309
|
+
}
|
|
310
|
+
return Object.freeze({ line, character })
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Validate severity (`1|2|3|4`), defaulting to `unknown`.
|
|
315
|
+
* @param {unknown} value - the raw severity.
|
|
316
|
+
* @returns {SeverityToken}
|
|
317
|
+
*/
|
|
318
|
+
function normalizeSeverity(value) {
|
|
319
|
+
if (value === undefined) return 'unknown'
|
|
320
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
321
|
+
throw new Error('diagnostic severity must be an integer 1|2|3|4')
|
|
322
|
+
}
|
|
323
|
+
const token = SEVERITY_TO_TOKEN[value]
|
|
324
|
+
if (token === undefined) throw new Error('diagnostic severity must be 1|2|3|4')
|
|
325
|
+
return token
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Validate code (safe integer or string), defaulting to ''.
|
|
330
|
+
* @param {unknown} value - the raw code.
|
|
331
|
+
* @returns {string}
|
|
332
|
+
*/
|
|
333
|
+
function normalizeCode(value) {
|
|
334
|
+
if (value === undefined) return ''
|
|
335
|
+
if (typeof value === 'number') {
|
|
336
|
+
if (!Number.isSafeInteger(value)) throw new Error('diagnostic code must be a safe integer or string')
|
|
337
|
+
return String(value)
|
|
338
|
+
}
|
|
339
|
+
if (typeof value === 'string') return value
|
|
340
|
+
throw new Error('diagnostic code must be a safe integer or string')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Validate source (string), defaulting to ''.
|
|
345
|
+
* @param {unknown} value - the raw source.
|
|
346
|
+
* @returns {string}
|
|
347
|
+
*/
|
|
348
|
+
function normalizeSource(value) {
|
|
349
|
+
if (value === undefined) return ''
|
|
350
|
+
if (typeof value !== 'string') throw new Error('diagnostic source must be a string')
|
|
351
|
+
return value
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Sanitize a consumed server string to single-line safe text: each CRLF, CR,
|
|
356
|
+
* LF, U+2028, or U+2029 becomes one space; every other C0/C1 control character
|
|
357
|
+
* and every lone surrogate becomes U+FFFD. Never adds output lines.
|
|
358
|
+
* @param {string} value - the raw server string.
|
|
359
|
+
* @returns {string}
|
|
360
|
+
*/
|
|
361
|
+
export function sanitizeServerString(value) {
|
|
362
|
+
let out = ''
|
|
363
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
364
|
+
const code = value.charCodeAt(index)
|
|
365
|
+
const char = value.charAt(index)
|
|
366
|
+
if (char === '\r') {
|
|
367
|
+
if (value.charAt(index + 1) === '\n') index += 1
|
|
368
|
+
out += ' '
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
if (char === '\n' || char === '\u2028' || char === '\u2029') {
|
|
372
|
+
out += ' '
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
|
|
376
|
+
out += '\uFFFD'
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
380
|
+
const next = value.charCodeAt(index + 1)
|
|
381
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
382
|
+
out += char + value.charAt(index + 1)
|
|
383
|
+
index += 1
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
out += '\uFFFD'
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
if (code >= 0xdc00 && code <= 0xdfff) {
|
|
390
|
+
out += '\uFFFD'
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
out += char
|
|
394
|
+
}
|
|
395
|
+
return out
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The diagnostics runtime: per-provider/workspace pooled LSP sessions.
|
|
399
|
+
*/
|
|
400
|
+
export class DiagnosticsRuntime {
|
|
401
|
+
/**
|
|
402
|
+
* @param {object} deps - runtime dependencies.
|
|
403
|
+
* @param {FsSeam} deps.fs - the public `ctx.fs` seam.
|
|
404
|
+
* @param {SubprocessSeam} deps.subprocess - the public `ctx.subprocess` seam.
|
|
405
|
+
* @param {RuntimeConfig} deps.config - the validated plugin config.
|
|
406
|
+
*/
|
|
407
|
+
constructor({ fs, subprocess, config }) {
|
|
408
|
+
/** @type {FsSeam} */
|
|
409
|
+
this.fs = fs
|
|
410
|
+
/** @type {SubprocessSeam} */
|
|
411
|
+
this.subprocess = subprocess
|
|
412
|
+
/** @type {RuntimeConfig} */
|
|
413
|
+
this.config = config
|
|
414
|
+
/** @type {Map<string, Map<string, LspSession>>} */
|
|
415
|
+
this.sessions = new Map()
|
|
416
|
+
/** @type {Map<string, Map<string, Promise<unknown>>>} */
|
|
417
|
+
this.tails = new Map()
|
|
418
|
+
/** @type {Set<Promise<void>>} */
|
|
419
|
+
this.retiredProcessCleanup = new Set()
|
|
420
|
+
/** @type {unknown[]} */
|
|
421
|
+
this.retiredProcessErrors = []
|
|
422
|
+
/** @type {boolean} */
|
|
423
|
+
this.admissionOpen = true
|
|
424
|
+
/** @type {Promise<void> | undefined} */
|
|
425
|
+
this.disposePromise = undefined
|
|
426
|
+
/** @type {Map<string, { providerId: string, languageId: string, server: ServerConfig }>} */
|
|
427
|
+
this.routes = new Map()
|
|
428
|
+
for (const [providerId, server] of Object.entries(config.servers)) {
|
|
429
|
+
for (const [extension, languageId] of Object.entries(server.extensionToLanguage)) {
|
|
430
|
+
this.routes.set(extension, { providerId, languageId, server })
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Synchronously close admission; no further diagnosis starts.
|
|
437
|
+
* @returns {void}
|
|
438
|
+
*/
|
|
439
|
+
stopAdmission() {
|
|
440
|
+
this.admissionOpen = false
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Dispose the runtime: stop admission, snapshot and clear the public pool,
|
|
445
|
+
* run every session's unique teardown, and await queues, session teardowns,
|
|
446
|
+
* and retired process cleanup together, aggregating cleanup errors.
|
|
447
|
+
* @returns {Promise<void>}
|
|
448
|
+
*/
|
|
449
|
+
dispose() {
|
|
450
|
+
this.stopAdmission()
|
|
451
|
+
if (this.disposePromise === undefined) {
|
|
452
|
+
this.disposePromise = this.performDispose()
|
|
453
|
+
}
|
|
454
|
+
return this.disposePromise
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* @returns {Promise<void>}
|
|
459
|
+
*/
|
|
460
|
+
async performDispose() {
|
|
461
|
+
/** @type {Set<LspSession>} */
|
|
462
|
+
const sessions = new Set()
|
|
463
|
+
for (const byKey of this.sessions.values()) {
|
|
464
|
+
for (const session of byKey.values()) sessions.add(session)
|
|
465
|
+
}
|
|
466
|
+
/** @type {Promise<unknown>[]} */
|
|
467
|
+
const queues = []
|
|
468
|
+
for (const byKey of this.tails.values()) {
|
|
469
|
+
for (const tail of byKey.values()) queues.push(tail)
|
|
470
|
+
}
|
|
471
|
+
this.sessions.clear()
|
|
472
|
+
this.tails.clear()
|
|
473
|
+
for (const session of sessions) this.startSessionTeardown(session)
|
|
474
|
+
/** @type {unknown[]} */
|
|
475
|
+
const errors = []
|
|
476
|
+
const queueResults = await Promise.allSettled(queues)
|
|
477
|
+
for (const result of queueResults) {
|
|
478
|
+
if (result.status === 'rejected') errors.push(result.reason)
|
|
479
|
+
}
|
|
480
|
+
while (this.retiredProcessCleanup.size > 0) {
|
|
481
|
+
await Promise.allSettled([...this.retiredProcessCleanup])
|
|
482
|
+
}
|
|
483
|
+
errors.push(...this.retiredProcessErrors)
|
|
484
|
+
this.retiredProcessErrors.length = 0
|
|
485
|
+
if (errors.length === 1) throw errors[0]
|
|
486
|
+
if (errors.length > 1) {
|
|
487
|
+
throw new AggregateError(errors, '@banbolee/dsh-lsp-diagnostics runtime dispose failed')
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Diagnose one written file after a successful mutation.
|
|
493
|
+
*
|
|
494
|
+
* The candidate is the collector's mutation candidate; the workspace is the
|
|
495
|
+
* coordinator-canonicalized workspace target, and `canonicalUri` is the
|
|
496
|
+
* eligibility-frozen canonical document URI the coordinator derived exactly
|
|
497
|
+
* once. The runtime never re-derives the document URI: the frozen value is
|
|
498
|
+
* used for route selection, bounded read, didOpen and the publication
|
|
499
|
+
* waiter, so correlation cannot drift from the coordinator's render side.
|
|
500
|
+
* Unsupported extensions, closed admission, and already-aborted signals are
|
|
501
|
+
* silent (`stale`). All workspace-visible work is serialized per (provider,
|
|
502
|
+
* workspace) through the two-level tail.
|
|
503
|
+
*
|
|
504
|
+
* @param {{ target: unknown, version: string, generation: number }} candidate - the mutated target.
|
|
505
|
+
* @param {{ targetKey: string, displayPath?: string }} canonicalWorkspace - the canonical workspace target.
|
|
506
|
+
* @param {string} canonicalUri - the eligibility-frozen canonical document URI.
|
|
507
|
+
* @param {AbortSignal} [executionSignal] - relayed caller/deadline/cleanup abort.
|
|
508
|
+
* @returns {Promise<DiagnosisOutcome>}
|
|
509
|
+
*/
|
|
510
|
+
async diagnose(candidate, canonicalWorkspace, canonicalUri, executionSignal) {
|
|
511
|
+
return this.diagnoseTarget(
|
|
512
|
+
candidate.target,
|
|
513
|
+
canonicalWorkspace,
|
|
514
|
+
canonicalUri,
|
|
515
|
+
executionSignal,
|
|
516
|
+
candidate.version,
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Diagnose one canonical target through the same provider/workspace queue and
|
|
522
|
+
* pooled sessions as the automatic post-write path.
|
|
523
|
+
*
|
|
524
|
+
* @param {unknown} target - the canonical document target.
|
|
525
|
+
* @param {{ targetKey: string, displayPath?: string }} canonicalWorkspace - the canonical workspace target.
|
|
526
|
+
* @param {string} canonicalUri - the canonical document URI.
|
|
527
|
+
* @param {AbortSignal} [executionSignal] - caller-owned cancellation.
|
|
528
|
+
* @param {string} [expectedVersion] - optional pre-diagnosis FsVersion freshness guard.
|
|
529
|
+
* @returns {Promise<DiagnosisOutcome>}
|
|
530
|
+
*/
|
|
531
|
+
async diagnoseTarget(target, canonicalWorkspace, canonicalUri, executionSignal, expectedVersion) {
|
|
532
|
+
if (!this.admissionOpen) return { kind: 'stale' }
|
|
533
|
+
if (executionSignal !== undefined && executionSignal.aborted) return { kind: 'stale' }
|
|
534
|
+
const uri = canonicalUri
|
|
535
|
+
const extension = extensionOf(uri)
|
|
536
|
+
const route = this.routes.get(extension)
|
|
537
|
+
if (route === undefined) return { kind: 'stale' }
|
|
538
|
+
const providerId = route.providerId
|
|
539
|
+
const workspaceKey = canonicalWorkspace.targetKey
|
|
540
|
+
try {
|
|
541
|
+
return await this.enqueue(
|
|
542
|
+
providerId,
|
|
543
|
+
workspaceKey,
|
|
544
|
+
() => this.runDiagnosis(target, canonicalWorkspace, route, uri, executionSignal, expectedVersion),
|
|
545
|
+
executionSignal,
|
|
546
|
+
)
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (executionSignal !== undefined && executionSignal.aborted) return { kind: 'stale' }
|
|
549
|
+
throw error
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Serialize one complete diagnosis lifecycle for a provider/workspace pair.
|
|
554
|
+
* @param {string} providerId - provider id.
|
|
555
|
+
* @param {string} workspaceKey - opaque canonical workspace key.
|
|
556
|
+
* @param {() => Promise<DiagnosisOutcome>} run - lifecycle to run after the prior tail.
|
|
557
|
+
* @param {AbortSignal} [signal] - operation cancellation while waiting in the queue.
|
|
558
|
+
* @returns {Promise<DiagnosisOutcome>}
|
|
559
|
+
*/
|
|
560
|
+
enqueue(providerId, workspaceKey, run, signal) {
|
|
561
|
+
let byKey = this.tails.get(providerId)
|
|
562
|
+
if (byKey === undefined) {
|
|
563
|
+
byKey = new Map()
|
|
564
|
+
this.tails.set(providerId, byKey)
|
|
565
|
+
}
|
|
566
|
+
const previous = byKey.get(workspaceKey) ?? Promise.resolve()
|
|
567
|
+
const result = abortable(previous, signal).then(run)
|
|
568
|
+
// The tail follows the ACTUAL prior work, never the abortable view, so a
|
|
569
|
+
// caller giving up on the wait does not deserialize the queue.
|
|
570
|
+
const tail = previous.then(() => result).then(() => undefined, () => undefined)
|
|
571
|
+
byKey.set(workspaceKey, tail)
|
|
572
|
+
void tail.then(() => {
|
|
573
|
+
if (byKey.get(workspaceKey) === tail) byKey.delete(workspaceKey)
|
|
574
|
+
})
|
|
575
|
+
return result
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Read the target document with the bounded stat-preflight + readBytes path.
|
|
580
|
+
* @param {unknown} target - the document target.
|
|
581
|
+
* @param {string} uri - canonical URI already derived for routing and correlation.
|
|
582
|
+
* @param {AbortSignal} [signal] - operation cancellation.
|
|
583
|
+
* @param {string} [expectedVersion] - optional FsVersion required before the read.
|
|
584
|
+
* @returns {Promise<{ kind: 'ok', uri: string, text: string } | { kind: 'stale' } | { kind: 'unavailable', reason: UnavailableReason }>}
|
|
585
|
+
*/
|
|
586
|
+
async readDocument(target, uri, signal, expectedVersion) {
|
|
587
|
+
/** @type {{ version: string, type: string, size?: number } | undefined} */
|
|
588
|
+
let info
|
|
589
|
+
try {
|
|
590
|
+
info = await this.fs.stat(target, signal)
|
|
591
|
+
} catch {
|
|
592
|
+
if (signal !== undefined && signal.aborted) {
|
|
593
|
+
return { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
594
|
+
}
|
|
595
|
+
return { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
596
|
+
}
|
|
597
|
+
if (signal !== undefined && signal.aborted) {
|
|
598
|
+
return { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
599
|
+
}
|
|
600
|
+
if (info === undefined || info.type !== 'file') {
|
|
601
|
+
return { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
602
|
+
}
|
|
603
|
+
if (expectedVersion !== undefined && info.version !== expectedVersion) {
|
|
604
|
+
return { kind: 'stale' }
|
|
605
|
+
}
|
|
606
|
+
if (typeof info.size === 'number' && info.size > this.config.maxDocumentBytes) {
|
|
607
|
+
return { kind: 'unavailable', reason: 'document too large' }
|
|
608
|
+
}
|
|
609
|
+
/** @type {Uint8Array} */
|
|
610
|
+
let bytes
|
|
611
|
+
try {
|
|
612
|
+
bytes = await this.fs.readBytes(target, signal, this.config.maxDocumentBytes)
|
|
613
|
+
} catch (error) {
|
|
614
|
+
if (signal !== undefined && signal.aborted) {
|
|
615
|
+
return { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
616
|
+
}
|
|
617
|
+
if (error instanceof FsError && error.code === 'FS_TOO_LARGE') {
|
|
618
|
+
return { kind: 'unavailable', reason: 'document too large' }
|
|
619
|
+
}
|
|
620
|
+
return { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
621
|
+
}
|
|
622
|
+
if (signal !== undefined && signal.aborted) {
|
|
623
|
+
return { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
624
|
+
}
|
|
625
|
+
let text
|
|
626
|
+
try {
|
|
627
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
628
|
+
} catch {
|
|
629
|
+
return { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
630
|
+
}
|
|
631
|
+
return { kind: 'ok', uri, text }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Run one diagnosis for an eligible route. Distinct URIs reuse the pooled
|
|
636
|
+
* provider/workspace session; a URI already opened by that process rotates to
|
|
637
|
+
* a fresh session before diagnosis. An unaccepted transport failure still
|
|
638
|
+
* receives at most one same-call restart.
|
|
639
|
+
* @param {unknown} target - the canonical document target.
|
|
640
|
+
* @param {{ targetKey: string, displayPath?: string }} canonicalWorkspace - the canonical workspace target.
|
|
641
|
+
* @param {{ providerId: string, languageId: string, server: ServerConfig }} route - the route.
|
|
642
|
+
* @param {string} uri - the canonical document URI.
|
|
643
|
+
* @param {AbortSignal} [executionSignal] - operation cancellation.
|
|
644
|
+
* @param {string} [expectedVersion] - optional FsVersion required before reading.
|
|
645
|
+
* @returns {Promise<DiagnosisOutcome>}
|
|
646
|
+
*/
|
|
647
|
+
async runDiagnosis(target, canonicalWorkspace, route, uri, executionSignal, expectedVersion) {
|
|
648
|
+
if (!this.admissionOpen) return { kind: 'stale' }
|
|
649
|
+
if (executionSignal !== undefined && executionSignal.aborted) return { kind: 'stale' }
|
|
650
|
+
const providerId = route.providerId
|
|
651
|
+
const workspaceKey = canonicalWorkspace.targetKey
|
|
652
|
+
const workspacePath = this.fs.processPath(canonicalWorkspace)
|
|
653
|
+
const workspaceUri = this.fs.fileUrl(canonicalWorkspace)
|
|
654
|
+
const read = await this.readDocument(target, uri, executionSignal, expectedVersion)
|
|
655
|
+
if (read.kind !== 'ok') return read
|
|
656
|
+
if (!this.admissionOpen) return { kind: 'stale' }
|
|
657
|
+
if (executionSignal !== undefined && executionSignal.aborted) return { kind: 'stale' }
|
|
658
|
+
let session = this.getOrCreateSession(providerId, workspaceKey, workspacePath, workspaceUri, route, executionSignal)
|
|
659
|
+
if (session.hasOpenedUri(uri)) {
|
|
660
|
+
this.evictIfCurrent(providerId, workspaceKey, session)
|
|
661
|
+
this.startSessionTeardown(session)
|
|
662
|
+
session = this.getOrCreateSession(providerId, workspaceKey, workspacePath, workspaceUri, route, executionSignal)
|
|
663
|
+
}
|
|
664
|
+
let attempt = 0
|
|
665
|
+
for (;;) {
|
|
666
|
+
try {
|
|
667
|
+
const outcome = await session.diagnose(uri, route.languageId, read.text, executionSignal)
|
|
668
|
+
if (executionSignal !== undefined && executionSignal.aborted) {
|
|
669
|
+
this.evictIfCurrent(providerId, workspaceKey, session)
|
|
670
|
+
this.startSessionTeardown(session)
|
|
671
|
+
} else if (session.retireAfterClose) {
|
|
672
|
+
this.evictIfCurrent(providerId, workspaceKey, session)
|
|
673
|
+
this.startSessionTeardown(session)
|
|
674
|
+
}
|
|
675
|
+
return outcome
|
|
676
|
+
} catch (error) {
|
|
677
|
+
if (executionSignal !== undefined && executionSignal.aborted) {
|
|
678
|
+
this.evictIfCurrent(providerId, workspaceKey, session)
|
|
679
|
+
this.startSessionTeardown(session)
|
|
680
|
+
return { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
681
|
+
}
|
|
682
|
+
if (!(error instanceof TransportError)) throw error
|
|
683
|
+
this.evictIfCurrent(providerId, workspaceKey, session)
|
|
684
|
+
this.startSessionTeardown(session)
|
|
685
|
+
if (session.acceptedPublication || attempt > 0) {
|
|
686
|
+
return { kind: 'unavailable', reason: error.reason }
|
|
687
|
+
}
|
|
688
|
+
if (!this.admissionOpen) return { kind: 'stale' }
|
|
689
|
+
attempt += 1
|
|
690
|
+
session = this.getOrCreateSession(providerId, workspaceKey, workspacePath, workspaceUri, route, executionSignal)
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Return the pooled session for a (provider, workspace) key, or synchronously
|
|
697
|
+
* publish a fresh one (before its initialize promise is exposed) and start it.
|
|
698
|
+
* @param {string} providerId - the provider id.
|
|
699
|
+
* @param {string} workspaceKey - the opaque workspace targetKey.
|
|
700
|
+
* @param {string} workspacePath - the subprocess cwd for this workspace.
|
|
701
|
+
* @param {string} workspaceUri - the canonical workspace root URI.
|
|
702
|
+
* @param {{ providerId: string, languageId: string, server: ServerConfig }} route - the route.
|
|
703
|
+
* @param {AbortSignal} [executionSignal] - the creating operation's signal.
|
|
704
|
+
* @returns {LspSession}
|
|
705
|
+
*/
|
|
706
|
+
getOrCreateSession(providerId, workspaceKey, workspacePath, workspaceUri, route, executionSignal) {
|
|
707
|
+
let byKey = this.sessions.get(providerId)
|
|
708
|
+
if (byKey === undefined) {
|
|
709
|
+
byKey = new Map()
|
|
710
|
+
this.sessions.set(providerId, byKey)
|
|
711
|
+
}
|
|
712
|
+
const existing = byKey.get(workspaceKey)
|
|
713
|
+
if (existing !== undefined && !existing.isClosing()) return existing
|
|
714
|
+
if (existing !== undefined) byKey.delete(workspaceKey)
|
|
715
|
+
const session = new LspSession({
|
|
716
|
+
subprocess: this.subprocess,
|
|
717
|
+
config: this.config,
|
|
718
|
+
workspacePath,
|
|
719
|
+
workspaceUri,
|
|
720
|
+
server: route.server,
|
|
721
|
+
onFatal: (failed) => {
|
|
722
|
+
this.evictIfCurrent(providerId, workspaceKey, failed)
|
|
723
|
+
this.startSessionTeardown(failed)
|
|
724
|
+
},
|
|
725
|
+
})
|
|
726
|
+
byKey.set(workspaceKey, session)
|
|
727
|
+
session.start(executionSignal)
|
|
728
|
+
return session
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Remove the pool slot iff it still holds this session.
|
|
733
|
+
* @param {string} providerId - the provider id.
|
|
734
|
+
* @param {string} workspaceKey - the workspace key.
|
|
735
|
+
* @param {LspSession} session - the session to evict.
|
|
736
|
+
* @returns {void}
|
|
737
|
+
*/
|
|
738
|
+
evictIfCurrent(providerId, workspaceKey, session) {
|
|
739
|
+
const byKey = this.sessions.get(providerId)
|
|
740
|
+
if (byKey === undefined) return
|
|
741
|
+
if (byKey.get(workspaceKey) === session) byKey.delete(workspaceKey)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Start a session teardown and track it as retired process cleanup.
|
|
746
|
+
* @param {LspSession} session - the session being retired.
|
|
747
|
+
* @returns {void}
|
|
748
|
+
*/
|
|
749
|
+
startSessionTeardown(session) {
|
|
750
|
+
const promise = session.startTeardown()
|
|
751
|
+
if (this.retiredProcessCleanup.has(promise)) return
|
|
752
|
+
this.retiredProcessCleanup.add(promise)
|
|
753
|
+
void promise.then(
|
|
754
|
+
() => this.retiredProcessCleanup.delete(promise),
|
|
755
|
+
(error) => {
|
|
756
|
+
this.retiredProcessCleanup.delete(promise)
|
|
757
|
+
this.retiredProcessErrors.push(error)
|
|
758
|
+
},
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* One pooled LSP session: a single subprocess plus the JSON-RPC state machine.
|
|
765
|
+
* @private
|
|
766
|
+
*/
|
|
767
|
+
class LspSession {
|
|
768
|
+
/**
|
|
769
|
+
* @param {object} deps - session dependencies.
|
|
770
|
+
* @param {SubprocessSeam} deps.subprocess - the subprocess seam.
|
|
771
|
+
* @param {RuntimeConfig} deps.config - the validated plugin config.
|
|
772
|
+
* @param {string} deps.workspacePath - the subprocess cwd.
|
|
773
|
+
* @param {string} deps.workspaceUri - the canonical workspace root URI.
|
|
774
|
+
* @param {ServerConfig} deps.server - the server config.
|
|
775
|
+
* @param {(session: LspSession) => void} deps.onFatal - runtime-owned eviction/teardown hook.
|
|
776
|
+
*/
|
|
777
|
+
constructor({ subprocess, config, workspacePath, workspaceUri, server, onFatal }) {
|
|
778
|
+
/** @type {SubprocessSeam} */
|
|
779
|
+
this.subprocess = subprocess
|
|
780
|
+
/** @type {RuntimeConfig} */
|
|
781
|
+
this.config = config
|
|
782
|
+
/** @type {string} */
|
|
783
|
+
this.workspacePath = workspacePath
|
|
784
|
+
/** @type {string} */
|
|
785
|
+
this.workspaceUri = workspaceUri
|
|
786
|
+
/** @type {ServerConfig} */
|
|
787
|
+
this.server = server
|
|
788
|
+
/** @type {(session: LspSession) => void} */
|
|
789
|
+
this.onFatal = onFatal
|
|
790
|
+
/** @type {MessageDecoder} */
|
|
791
|
+
this.decoder = new MessageDecoder(config.maxMessageBytes)
|
|
792
|
+
/** @type {SessionHandle | undefined} */
|
|
793
|
+
this.handle = undefined
|
|
794
|
+
/** @type {{ write(chunk: Buffer, callback?: (error?: Error | null) => void): void, on(event: 'error', listener: (error: Error) => void): void, removeListener?(event: 'error', listener: (error: Error) => void): void } | undefined} */
|
|
795
|
+
this.stdin = undefined
|
|
796
|
+
/** @type {import('node:events').EventEmitter | undefined} */
|
|
797
|
+
this.stdout = undefined
|
|
798
|
+
/** @type {(error: Error) => void} */
|
|
799
|
+
this.onStdinError = (error) => {
|
|
800
|
+
if (!this.closing) this.poison(new TransportError('malformed response', error))
|
|
801
|
+
}
|
|
802
|
+
/** @type {(chunk: Buffer) => void} */
|
|
803
|
+
this.onStdoutData = (chunk) => {
|
|
804
|
+
this.onStdout(chunk)
|
|
805
|
+
}
|
|
806
|
+
/** @type {(error: Error) => void} */
|
|
807
|
+
this.onStdoutError = (error) => {
|
|
808
|
+
if (!this.closing) this.poison(new TransportError('malformed response', error))
|
|
809
|
+
}
|
|
810
|
+
/** @type {Map<number, { resolve: (value: unknown) => void, reject: (error: Error) => void, signal?: AbortSignal, onAbort?: () => void }>} */
|
|
811
|
+
this.pending = new Map()
|
|
812
|
+
/** @type {Set<Promise<void>>} */
|
|
813
|
+
this.serverRequests = new Set()
|
|
814
|
+
/** @type {number} */
|
|
815
|
+
this.nextId = 1
|
|
816
|
+
/** @type {Promise<void>} */
|
|
817
|
+
this.writeTail = Promise.resolve()
|
|
818
|
+
/** @type {Promise<void> | undefined} */
|
|
819
|
+
this.ready = undefined
|
|
820
|
+
/** @type {Map<string, number>} */
|
|
821
|
+
this.openVersions = new Map()
|
|
822
|
+
/** @type {Waiter | null} */
|
|
823
|
+
this.waiter = null
|
|
824
|
+
/** @type {AbortController} */
|
|
825
|
+
this.lifetimeController = new AbortController()
|
|
826
|
+
/** @type {Promise<void> | undefined} */
|
|
827
|
+
this.teardownPromise = undefined
|
|
828
|
+
/** @type {boolean} */
|
|
829
|
+
this.closing = false
|
|
830
|
+
/** @type {boolean} */
|
|
831
|
+
this.poisoned = false
|
|
832
|
+
/** @type {TransportError | undefined} */
|
|
833
|
+
this.poisonError = undefined
|
|
834
|
+
/** @type {boolean} */
|
|
835
|
+
this.acceptedPublication = false
|
|
836
|
+
/** @type {boolean} */
|
|
837
|
+
this.retireAfterClose = false
|
|
838
|
+
/** @type {Set<string>} */
|
|
839
|
+
this.openedUris = new Set()
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Whether the session must not be reused (closing or poisoned).
|
|
844
|
+
* @returns {boolean}
|
|
845
|
+
*/
|
|
846
|
+
isClosing() {
|
|
847
|
+
return this.closing || this.poisoned
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Whether this process has already completed or attempted an open lifecycle
|
|
852
|
+
* for the canonical URI.
|
|
853
|
+
* @param {string} uri - canonical document URI.
|
|
854
|
+
* @returns {boolean}
|
|
855
|
+
*/
|
|
856
|
+
hasOpenedUri(uri) {
|
|
857
|
+
return this.openedUris.has(uri)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Spawn the server and start the single-flight initialize handshake.
|
|
862
|
+
* @param {AbortSignal} [executionSignal] - the creating operation's signal.
|
|
863
|
+
* @returns {void}
|
|
864
|
+
*/
|
|
865
|
+
start(executionSignal) {
|
|
866
|
+
this.ready = this.bootstrap(executionSignal)
|
|
867
|
+
this.ready.catch(() => {})
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Spawn, initialize, and validate the server.
|
|
872
|
+
* @param {AbortSignal} [executionSignal] - the creating operation's signal.
|
|
873
|
+
* @returns {Promise<void>}
|
|
874
|
+
*/
|
|
875
|
+
async bootstrap(executionSignal) {
|
|
876
|
+
/** @type {string} */
|
|
877
|
+
let executable
|
|
878
|
+
try {
|
|
879
|
+
executable = await this.subprocess.resolveExecutable(this.server.command, this.server.env, executionSignal)
|
|
880
|
+
} catch (error) {
|
|
881
|
+
if (executionSignal !== undefined && executionSignal.aborted) throw abortError(executionSignal)
|
|
882
|
+
throw new TransportError('server not found', error)
|
|
883
|
+
}
|
|
884
|
+
if (executionSignal !== undefined && executionSignal.aborted) throw abortError(executionSignal)
|
|
885
|
+
/** @type {SessionHandle} */
|
|
886
|
+
let handle
|
|
887
|
+
try {
|
|
888
|
+
handle = this.subprocess.spawn({
|
|
889
|
+
argv: [executable, ...this.server.args],
|
|
890
|
+
cwd: this.workspacePath,
|
|
891
|
+
stdio: {
|
|
892
|
+
stdin: 'pipe',
|
|
893
|
+
stdout: 'pipe',
|
|
894
|
+
stderr: { maxBytes: this.config.maxStderrBytes },
|
|
895
|
+
},
|
|
896
|
+
graceMs: this.config.killGraceMs,
|
|
897
|
+
signal: this.lifetimeController.signal,
|
|
898
|
+
env: this.server.env,
|
|
899
|
+
})
|
|
900
|
+
} catch (error) {
|
|
901
|
+
throw new TransportError('server crashed', error)
|
|
902
|
+
}
|
|
903
|
+
this.handle = handle
|
|
904
|
+
if (handle.stdin === undefined || handle.stdout === undefined) {
|
|
905
|
+
throw new TransportError('server crashed', new Error('subprocess dropped a piped protocol stream'))
|
|
906
|
+
}
|
|
907
|
+
this.stdin = handle.stdin
|
|
908
|
+
this.stdout = handle.stdout
|
|
909
|
+
handle.done.then(
|
|
910
|
+
() => {
|
|
911
|
+
this.processExited = true
|
|
912
|
+
if (!this.closing) this.poison(new TransportError('server crashed', new Error('language server exited')))
|
|
913
|
+
},
|
|
914
|
+
(error) => {
|
|
915
|
+
this.processExited = true
|
|
916
|
+
if (!this.closing) this.poison(new TransportError('server crashed', error))
|
|
917
|
+
},
|
|
918
|
+
)
|
|
919
|
+
this.stdin.on('error', this.onStdinError)
|
|
920
|
+
this.stdout.on('data', this.onStdoutData)
|
|
921
|
+
this.stdout.on('error', this.onStdoutError)
|
|
922
|
+
const initializeResult = await this.request(
|
|
923
|
+
'initialize',
|
|
924
|
+
{
|
|
925
|
+
processId: null,
|
|
926
|
+
rootUri: this.workspaceUri,
|
|
927
|
+
workspaceFolders: [{ uri: this.workspaceUri, name: 'workspace' }],
|
|
928
|
+
capabilities: CLIENT_CAPABILITIES,
|
|
929
|
+
initializationOptions: this.server.initializationOptions,
|
|
930
|
+
},
|
|
931
|
+
executionSignal,
|
|
932
|
+
).catch(async (error) => {
|
|
933
|
+
if (executionSignal !== undefined && executionSignal.aborted) throw abortError(executionSignal)
|
|
934
|
+
if (error instanceof TransportError && error.writeFailure === true) {
|
|
935
|
+
// A stdin write failure (e.g. EPIPE) can beat the process-exit
|
|
936
|
+
// observation: when the server exited before its initialize response,
|
|
937
|
+
// the classification must stay `server crashed`, not degrade to
|
|
938
|
+
// `malformed response`. Decoder/protocol failures are never
|
|
939
|
+
// reclassified — only write failures race the process exit.
|
|
940
|
+
const cause = error.cause
|
|
941
|
+
const isEpipe =
|
|
942
|
+
cause instanceof Error &&
|
|
943
|
+
(/** @type {{ code?: unknown }} */ (/** @type {unknown} */ (cause)).code === 'EPIPE' ||
|
|
944
|
+
/EPIPE/.test(cause.message))
|
|
945
|
+
if (isEpipe || this.processExited) throw new TransportError('server crashed', error)
|
|
946
|
+
if (this.handle !== undefined) {
|
|
947
|
+
// Give handle.done a macrotask window to settle for the
|
|
948
|
+
// EPIPE-before-exit ordering before falling back to the original
|
|
949
|
+
// classification (malformed response).
|
|
950
|
+
const exited = await Promise.race([
|
|
951
|
+
this.handle.done.then(() => true, () => true),
|
|
952
|
+
new Promise((resolve) => setTimeout(resolve, 0)).then(() => false),
|
|
953
|
+
])
|
|
954
|
+
if (exited) throw new TransportError('server crashed', error)
|
|
955
|
+
}
|
|
956
|
+
throw error
|
|
957
|
+
}
|
|
958
|
+
if (error instanceof TransportError) throw error
|
|
959
|
+
throw new TransportError('malformed response', error)
|
|
960
|
+
})
|
|
961
|
+
const initializeRecord = /** @type {{ capabilities?: unknown } | null} */ (initializeResult)
|
|
962
|
+
if (
|
|
963
|
+
typeof initializeRecord !== 'object' ||
|
|
964
|
+
initializeRecord === null ||
|
|
965
|
+
typeof initializeRecord.capabilities !== 'object' ||
|
|
966
|
+
initializeRecord.capabilities === null ||
|
|
967
|
+
Array.isArray(initializeRecord.capabilities)
|
|
968
|
+
) {
|
|
969
|
+
throw new TransportError('malformed response', new Error('initialize result must contain object capabilities'))
|
|
970
|
+
}
|
|
971
|
+
await abortable(
|
|
972
|
+
this.write({ jsonrpc: '2.0', method: 'initialized', params: {} }),
|
|
973
|
+
executionSignal,
|
|
974
|
+
)
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Diagnose one open lifecycle: wait ready, arm the waiter, write didOpen,
|
|
979
|
+
* wait the quiet window, then close-or-evict.
|
|
980
|
+
* @param {string} uri - the canonical document URI.
|
|
981
|
+
* @param {string} languageId - the LSP language id.
|
|
982
|
+
* @param {string} text - the decoded document text.
|
|
983
|
+
* @param {AbortSignal} [executionSignal] - operation cancellation.
|
|
984
|
+
* @returns {Promise<DiagnosisOutcome>}
|
|
985
|
+
*/
|
|
986
|
+
async diagnose(uri, languageId, text, executionSignal) {
|
|
987
|
+
if (this.isClosing()) throw new TransportError('server crashed', new Error('session is closing'))
|
|
988
|
+
this.acceptedPublication = false
|
|
989
|
+
this.retireAfterClose = false
|
|
990
|
+
try {
|
|
991
|
+
const ready = this.ready
|
|
992
|
+
if (ready === undefined) throw new TransportError('server crashed', new Error('session was not started'))
|
|
993
|
+
await abortable(ready, executionSignal)
|
|
994
|
+
} catch (error) {
|
|
995
|
+
if (executionSignal !== undefined && executionSignal.aborted) {
|
|
996
|
+
return { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
997
|
+
}
|
|
998
|
+
throw error
|
|
999
|
+
}
|
|
1000
|
+
if (this.poisoned) throw this.poisonError
|
|
1001
|
+
const version = (this.openVersions.get(uri) ?? 0) + 1
|
|
1002
|
+
this.openVersions.set(uri, version)
|
|
1003
|
+
const firstOpen = !this.openedUris.has(uri)
|
|
1004
|
+
this.openedUris.add(uri)
|
|
1005
|
+
const waiter = this.armWaiter(uri, version, firstOpen, executionSignal)
|
|
1006
|
+
// The waiter is only awaited after a successful didOpen write; a write
|
|
1007
|
+
// failure settles it through poison before this function can await it, so
|
|
1008
|
+
// its rejection must stay observed (no unhandled rejection).
|
|
1009
|
+
void waiter.promise.catch(() => {})
|
|
1010
|
+
let opened = false
|
|
1011
|
+
/** @type {DiagnosisOutcome} */
|
|
1012
|
+
let outcome = { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
1013
|
+
let closeFailed = false
|
|
1014
|
+
try {
|
|
1015
|
+
await abortable(
|
|
1016
|
+
this.write({
|
|
1017
|
+
jsonrpc: '2.0',
|
|
1018
|
+
method: 'textDocument/didOpen',
|
|
1019
|
+
params: { textDocument: { uri, languageId, version, text } },
|
|
1020
|
+
}),
|
|
1021
|
+
executionSignal,
|
|
1022
|
+
)
|
|
1023
|
+
opened = true
|
|
1024
|
+
// Only a successfully written didOpen generation accepts notifications:
|
|
1025
|
+
// flush any publication that raced the write callback now.
|
|
1026
|
+
waiter.opened = true
|
|
1027
|
+
if (waiter.preOpenBatch !== null) {
|
|
1028
|
+
this.acceptBatch(waiter.preOpenBatch)
|
|
1029
|
+
waiter.preOpenBatch = null
|
|
1030
|
+
}
|
|
1031
|
+
outcome = await waiter.promise
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
if (executionSignal !== undefined && executionSignal.aborted) {
|
|
1034
|
+
outcome = { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
1035
|
+
} else {
|
|
1036
|
+
throw error
|
|
1037
|
+
}
|
|
1038
|
+
} finally {
|
|
1039
|
+
if (opened && !this.isClosing()) {
|
|
1040
|
+
if (executionSignal !== undefined && executionSignal.aborted) {
|
|
1041
|
+
this.poison(new TransportError('malformed response', new Error('deadline passed while open')))
|
|
1042
|
+
closeFailed = true
|
|
1043
|
+
} else {
|
|
1044
|
+
try {
|
|
1045
|
+
await abortable(
|
|
1046
|
+
this.write({
|
|
1047
|
+
jsonrpc: '2.0',
|
|
1048
|
+
method: 'textDocument/didClose',
|
|
1049
|
+
params: { textDocument: { uri } },
|
|
1050
|
+
}),
|
|
1051
|
+
executionSignal,
|
|
1052
|
+
)
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
this.poison(new TransportError('malformed response', error))
|
|
1055
|
+
closeFailed = true
|
|
1056
|
+
if (executionSignal !== undefined && executionSignal.aborted) {
|
|
1057
|
+
outcome = { kind: 'unavailable', reason: 'diagnostics unavailable' }
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
this.detachWaiter()
|
|
1063
|
+
}
|
|
1064
|
+
if (closeFailed && outcome.kind === 'ok') {
|
|
1065
|
+
return { kind: 'unavailable', reason: 'malformed response' }
|
|
1066
|
+
}
|
|
1067
|
+
return outcome
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Arm the diagnostic waiter BEFORE the didOpen write. The waiter is
|
|
1072
|
+
* considered activated immediately: a child that reads didOpen from the
|
|
1073
|
+
* pipe can publish before Node invokes the write callback continuation, and
|
|
1074
|
+
* that matching publication must be kept, never dropped. If the write
|
|
1075
|
+
* itself later fails, the poison/teardown path still wins.
|
|
1076
|
+
* @param {string} uri - the exact canonical URI.
|
|
1077
|
+
* @param {number} version - the didOpen document version.
|
|
1078
|
+
* @param {boolean} firstOpen - whether this process opens this URI for the first time.
|
|
1079
|
+
* @param {AbortSignal} [executionSignal] - operation cancellation.
|
|
1080
|
+
* @returns {Waiter}
|
|
1081
|
+
*/
|
|
1082
|
+
armWaiter(uri, version, firstOpen, executionSignal) {
|
|
1083
|
+
/** @type {(value: DiagnosisOutcome) => void} */
|
|
1084
|
+
let resolve = () => {}
|
|
1085
|
+
/** @type {(reason: Error) => void} */
|
|
1086
|
+
let reject = () => {}
|
|
1087
|
+
const promise = new Promise((res, rej) => {
|
|
1088
|
+
resolve = res
|
|
1089
|
+
reject = rej
|
|
1090
|
+
})
|
|
1091
|
+
/** @type {Waiter} */
|
|
1092
|
+
const waiter = {
|
|
1093
|
+
canonicalUri: uri,
|
|
1094
|
+
currentVersion: version,
|
|
1095
|
+
firstOpen,
|
|
1096
|
+
opened: false,
|
|
1097
|
+
preOpenBatch: null,
|
|
1098
|
+
settled: false,
|
|
1099
|
+
batch: null,
|
|
1100
|
+
timer: undefined,
|
|
1101
|
+
promise,
|
|
1102
|
+
resolve,
|
|
1103
|
+
reject,
|
|
1104
|
+
signal: executionSignal,
|
|
1105
|
+
onAbort: undefined,
|
|
1106
|
+
}
|
|
1107
|
+
waiter.onAbort = () => {
|
|
1108
|
+
if (waiter.settled) return
|
|
1109
|
+
waiter.settled = true
|
|
1110
|
+
if (waiter.timer !== undefined) clearTimeout(waiter.timer)
|
|
1111
|
+
resolve({ kind: 'unavailable', reason: 'diagnostics unavailable' })
|
|
1112
|
+
}
|
|
1113
|
+
if (executionSignal !== undefined) {
|
|
1114
|
+
executionSignal.addEventListener('abort', waiter.onAbort, { once: true })
|
|
1115
|
+
}
|
|
1116
|
+
this.waiter = waiter
|
|
1117
|
+
return waiter
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Remove the waiter's abort listener and timer and drop the active reference.
|
|
1122
|
+
* @returns {void}
|
|
1123
|
+
*/
|
|
1124
|
+
detachWaiter() {
|
|
1125
|
+
const waiter = this.waiter
|
|
1126
|
+
if (waiter === null) return
|
|
1127
|
+
if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
|
|
1128
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort)
|
|
1129
|
+
}
|
|
1130
|
+
if (waiter.timer !== undefined) clearTimeout(waiter.timer)
|
|
1131
|
+
this.waiter = null
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Start (or restart) the quiet window for the active waiter. The window only
|
|
1136
|
+
* runs inside the execution deadline: the abort listener settles the waiter
|
|
1137
|
+
* first and clears the timer.
|
|
1138
|
+
* @returns {void}
|
|
1139
|
+
*/
|
|
1140
|
+
startQuietTimer() {
|
|
1141
|
+
const waiter = this.waiter
|
|
1142
|
+
if (waiter === null || waiter.settled) return
|
|
1143
|
+
if (waiter.timer !== undefined) clearTimeout(waiter.timer)
|
|
1144
|
+
waiter.timer = setTimeout(() => {
|
|
1145
|
+
if (waiter.settled) return
|
|
1146
|
+
waiter.settled = true
|
|
1147
|
+
if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
|
|
1148
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort)
|
|
1149
|
+
}
|
|
1150
|
+
if (waiter.batch !== null) {
|
|
1151
|
+
waiter.resolve({
|
|
1152
|
+
kind: 'ok',
|
|
1153
|
+
diagnostics: waiter.batch,
|
|
1154
|
+
uri: waiter.canonicalUri,
|
|
1155
|
+
version: waiter.currentVersion,
|
|
1156
|
+
})
|
|
1157
|
+
} else {
|
|
1158
|
+
waiter.resolve({ kind: 'unavailable', reason: 'timeout' })
|
|
1159
|
+
}
|
|
1160
|
+
}, this.config.settleMs)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Accept a matching batch: keep the latest full array for this version and
|
|
1165
|
+
* restart the quiet window.
|
|
1166
|
+
* @param {NormalizedDiagnostic[]} diagnostics - the normalized batch.
|
|
1167
|
+
* @returns {void}
|
|
1168
|
+
*/
|
|
1169
|
+
acceptBatch(diagnostics) {
|
|
1170
|
+
const waiter = this.waiter
|
|
1171
|
+
if (waiter === null || waiter.settled) return
|
|
1172
|
+
waiter.batch = diagnostics
|
|
1173
|
+
this.acceptedPublication = true
|
|
1174
|
+
this.startQuietTimer()
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Consume stdout bytes through the bounded decoder.
|
|
1179
|
+
* @param {Buffer} chunk - raw stdout bytes.
|
|
1180
|
+
* @returns {void}
|
|
1181
|
+
*/
|
|
1182
|
+
onStdout(chunk) {
|
|
1183
|
+
/** @type {unknown[]} */
|
|
1184
|
+
let messages
|
|
1185
|
+
try {
|
|
1186
|
+
messages = this.decoder.push(chunk)
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
this.poison(new TransportError('malformed response', error))
|
|
1189
|
+
return
|
|
1190
|
+
}
|
|
1191
|
+
for (const message of messages) this.dispatch(message)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Route one decoded JSON-RPC message.
|
|
1196
|
+
* @param {unknown} message - the decoded frame.
|
|
1197
|
+
* @returns {void}
|
|
1198
|
+
*/
|
|
1199
|
+
dispatch(message) {
|
|
1200
|
+
if (typeof message !== 'object' || message === null) return
|
|
1201
|
+
const frame = /** @type {Record<string, unknown>} */ (message)
|
|
1202
|
+
const id = frame.id
|
|
1203
|
+
const method = frame.method
|
|
1204
|
+
if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) {
|
|
1205
|
+
// While closing, no new server-request handlers start: teardown owns the
|
|
1206
|
+
// already-started handlers and the write tail so no protocol I/O can
|
|
1207
|
+
// happen after cleanup resolves.
|
|
1208
|
+
if (this.closing) return
|
|
1209
|
+
const task = this.handleServerRequest(id, method, frame.params)
|
|
1210
|
+
this.serverRequests.add(task)
|
|
1211
|
+
void task.then(
|
|
1212
|
+
() => this.serverRequests.delete(task),
|
|
1213
|
+
() => this.serverRequests.delete(task),
|
|
1214
|
+
)
|
|
1215
|
+
return
|
|
1216
|
+
}
|
|
1217
|
+
if (typeof method === 'string') {
|
|
1218
|
+
if (method === 'textDocument/publishDiagnostics') this.handlePublish(frame.params)
|
|
1219
|
+
return
|
|
1220
|
+
}
|
|
1221
|
+
if (typeof id === 'number') this.handleResponse(id, frame)
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Answer a server→client request: static configuration for
|
|
1226
|
+
* `workspace/configuration`, `null` for lifecycle no-ops, and JSON-RPC
|
|
1227
|
+
* -32601 for `workspace/applyEdit` and anything else. The client never
|
|
1228
|
+
* executes server edits or commands.
|
|
1229
|
+
* @param {number | string} id - the request id.
|
|
1230
|
+
* @param {string} method - the request method.
|
|
1231
|
+
* @param {unknown} params - the request params.
|
|
1232
|
+
* @returns {Promise<void>}
|
|
1233
|
+
*/
|
|
1234
|
+
async handleServerRequest(id, method, params) {
|
|
1235
|
+
if (method === 'workspace/configuration') {
|
|
1236
|
+
const items = /** @type {{ items?: unknown[] } | null | undefined} */ (params)?.items
|
|
1237
|
+
const count = Array.isArray(items) ? items.length : 0
|
|
1238
|
+
await this.write({
|
|
1239
|
+
jsonrpc: '2.0',
|
|
1240
|
+
id,
|
|
1241
|
+
result: Array.from({ length: count }, () => this.server.configuration),
|
|
1242
|
+
})
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1245
|
+
if (LIFECYCLE_NOOP_METHODS.has(method)) {
|
|
1246
|
+
await this.write({ jsonrpc: '2.0', id, result: null })
|
|
1247
|
+
return
|
|
1248
|
+
}
|
|
1249
|
+
await this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${method}` } })
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Correlate a response by numeric id; any shape violation is a fatal
|
|
1254
|
+
* protocol failure. Unknown response ids are ignored.
|
|
1255
|
+
* @param {number} id - the response id.
|
|
1256
|
+
* @param {Record<string, unknown>} frame - the response frame.
|
|
1257
|
+
* @returns {void}
|
|
1258
|
+
*/
|
|
1259
|
+
handleResponse(id, frame) {
|
|
1260
|
+
const entry = this.pending.get(id)
|
|
1261
|
+
if (entry === undefined) return
|
|
1262
|
+
this.pending.delete(id)
|
|
1263
|
+
if (entry.signal !== undefined && entry.onAbort !== undefined) {
|
|
1264
|
+
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
1265
|
+
}
|
|
1266
|
+
if (frame.jsonrpc !== '2.0') {
|
|
1267
|
+
const error = new TransportError('malformed response', new Error('response must be jsonrpc 2.0'))
|
|
1268
|
+
entry.reject(error)
|
|
1269
|
+
this.poison(error)
|
|
1270
|
+
return
|
|
1271
|
+
}
|
|
1272
|
+
const hasResult = Object.prototype.hasOwnProperty.call(frame, 'result')
|
|
1273
|
+
const hasError = Object.prototype.hasOwnProperty.call(frame, 'error')
|
|
1274
|
+
if (hasResult === hasError) {
|
|
1275
|
+
const error = new TransportError('malformed response', new Error('response must have exactly one of result or error'))
|
|
1276
|
+
entry.reject(error)
|
|
1277
|
+
this.poison(error)
|
|
1278
|
+
return
|
|
1279
|
+
}
|
|
1280
|
+
if (hasError) {
|
|
1281
|
+
const error = /** @type {{ code?: unknown, message?: unknown } | null} */ (frame.error)
|
|
1282
|
+
if (
|
|
1283
|
+
typeof error !== 'object' ||
|
|
1284
|
+
error === null ||
|
|
1285
|
+
typeof error.code !== 'number' ||
|
|
1286
|
+
typeof error.message !== 'string'
|
|
1287
|
+
) {
|
|
1288
|
+
const failure = new TransportError('malformed response', new Error('error response must carry a numeric code and string message'))
|
|
1289
|
+
entry.reject(failure)
|
|
1290
|
+
this.poison(failure)
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1293
|
+
entry.reject(new Error(error.message))
|
|
1294
|
+
return
|
|
1295
|
+
}
|
|
1296
|
+
entry.resolve(frame.result)
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Process one `textDocument/publishDiagnostics` notification: exact-URI-first
|
|
1301
|
+
* correlation, then version correlation, then strict consumed-field
|
|
1302
|
+
* projection. Cross-URI publications are ignored entirely.
|
|
1303
|
+
* @param {unknown} params - the notification params.
|
|
1304
|
+
* @returns {void}
|
|
1305
|
+
*/
|
|
1306
|
+
handlePublish(params) {
|
|
1307
|
+
const waiter = this.waiter
|
|
1308
|
+
if (waiter === null || waiter.settled) return
|
|
1309
|
+
const record = /** @type {Record<string, unknown> | null} */ (params)
|
|
1310
|
+
if (typeof record !== 'object' || record === null || typeof record.uri !== 'string') {
|
|
1311
|
+
this.poison(new TransportError('malformed response', new Error('publishDiagnostics params must be an object with a string uri')))
|
|
1312
|
+
return
|
|
1313
|
+
}
|
|
1314
|
+
if (record.uri !== waiter.canonicalUri) return
|
|
1315
|
+
for (const key of Object.keys(record)) {
|
|
1316
|
+
if (key !== 'uri' && key !== 'version' && key !== 'diagnostics') {
|
|
1317
|
+
this.poison(new TransportError('malformed response', new Error(`unknown publishDiagnostics field ${key}`)))
|
|
1318
|
+
return
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
if ('version' in record) {
|
|
1322
|
+
const version = record.version
|
|
1323
|
+
if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) {
|
|
1324
|
+
this.poison(new TransportError('malformed response', new Error('version must be a non-negative safe integer')))
|
|
1325
|
+
return
|
|
1326
|
+
}
|
|
1327
|
+
if (version < waiter.currentVersion) return
|
|
1328
|
+
if (version > waiter.currentVersion) {
|
|
1329
|
+
this.poison(new TransportError('malformed response', new Error('future version publication')))
|
|
1330
|
+
return
|
|
1331
|
+
}
|
|
1332
|
+
} else {
|
|
1333
|
+
if (!waiter.firstOpen) {
|
|
1334
|
+
this.poison(new TransportError('malformed response', new Error('versionless publication on a re-opened uri')))
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
this.retireAfterClose = true
|
|
1338
|
+
}
|
|
1339
|
+
if (!Array.isArray(record.diagnostics)) {
|
|
1340
|
+
this.poison(new TransportError('malformed response', new Error('diagnostics must be an array')))
|
|
1341
|
+
return
|
|
1342
|
+
}
|
|
1343
|
+
/** @type {NormalizedDiagnostic[]} */
|
|
1344
|
+
const diagnostics = []
|
|
1345
|
+
for (const diagnostic of record.diagnostics) {
|
|
1346
|
+
try {
|
|
1347
|
+
diagnostics.push(normalizeDiagnostic(waiter.canonicalUri, diagnostic))
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
this.poison(new TransportError('malformed response', error))
|
|
1350
|
+
return
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
// A publication that races the didOpen write callback is buffered, never
|
|
1354
|
+
// accepted: only a successfully written open generation may accept
|
|
1355
|
+
// notifications, and a pre-open publication must not set
|
|
1356
|
+
// acceptedPublication (which would suppress the allowed fresh-instance
|
|
1357
|
+
// retry after a failed didOpen write).
|
|
1358
|
+
if (!waiter.opened) {
|
|
1359
|
+
waiter.preOpenBatch = diagnostics
|
|
1360
|
+
return
|
|
1361
|
+
}
|
|
1362
|
+
this.acceptBatch(diagnostics)
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Send a request and await its response, registered before the write.
|
|
1367
|
+
* @param {string} method - the JSON-RPC method.
|
|
1368
|
+
* @param {unknown} params - the params.
|
|
1369
|
+
* @param {AbortSignal} [signal] - optional cancellation for this request.
|
|
1370
|
+
* @param {boolean} [teardownOwned] - allow during closing (teardown's shutdown).
|
|
1371
|
+
* @returns {Promise<unknown>}
|
|
1372
|
+
*/
|
|
1373
|
+
request(method, params, signal, teardownOwned = false) {
|
|
1374
|
+
if (this.closing && !teardownOwned) {
|
|
1375
|
+
return Promise.reject(new Error('session is closing'))
|
|
1376
|
+
}
|
|
1377
|
+
if (signal !== undefined && signal.aborted) {
|
|
1378
|
+
return Promise.reject(abortError(signal))
|
|
1379
|
+
}
|
|
1380
|
+
const id = this.nextId++
|
|
1381
|
+
/** @type {{ resolve: (value: unknown) => void, reject: (error: Error) => void, signal?: AbortSignal, onAbort?: () => void } | undefined} */
|
|
1382
|
+
let entry
|
|
1383
|
+
const promise = new Promise((resolve, reject) => {
|
|
1384
|
+
entry = { resolve, reject }
|
|
1385
|
+
this.pending.set(id, entry)
|
|
1386
|
+
if (signal !== undefined) {
|
|
1387
|
+
entry.signal = signal
|
|
1388
|
+
const onAbort = () => {
|
|
1389
|
+
if (this.pending.get(id) === entry) this.pending.delete(id)
|
|
1390
|
+
reject(abortError(signal))
|
|
1391
|
+
}
|
|
1392
|
+
entry.onAbort = onAbort
|
|
1393
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
1394
|
+
}
|
|
1395
|
+
})
|
|
1396
|
+
this.write({ jsonrpc: '2.0', id, method, params }).catch((error) => {
|
|
1397
|
+
if (entry === undefined || this.pending.get(id) !== entry) return
|
|
1398
|
+
this.pending.delete(id)
|
|
1399
|
+
if (entry.signal !== undefined && entry.onAbort !== undefined) {
|
|
1400
|
+
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
1401
|
+
}
|
|
1402
|
+
const failure = new TransportError('malformed response', error)
|
|
1403
|
+
if (error instanceof TransportError && error.writeFailure === true) failure.writeFailure = true
|
|
1404
|
+
entry.reject(failure)
|
|
1405
|
+
if (!teardownOwned) this.poison(failure)
|
|
1406
|
+
})
|
|
1407
|
+
return promise
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Serialize one outbound frame through the single write tail.
|
|
1412
|
+
* @param {unknown} message - the JSON-RPC message.
|
|
1413
|
+
* @returns {Promise<void>}
|
|
1414
|
+
*/
|
|
1415
|
+
write(message) {
|
|
1416
|
+
const frame = encodeMessage(message)
|
|
1417
|
+
const next = this.writeTail.then(
|
|
1418
|
+
() =>
|
|
1419
|
+
new Promise(/** @param {(value?: void) => void} resolve @param {(error: Error) => void} reject */ (resolve, reject) => {
|
|
1420
|
+
if (this.stdin === undefined) {
|
|
1421
|
+
reject(new TransportError('server crashed', new Error('session has no stdin')))
|
|
1422
|
+
return
|
|
1423
|
+
}
|
|
1424
|
+
try {
|
|
1425
|
+
this.stdin.write(frame, (error) => {
|
|
1426
|
+
if (error) {
|
|
1427
|
+
const failure = new TransportError('malformed response', error)
|
|
1428
|
+
failure.writeFailure = true
|
|
1429
|
+
if (!this.closing) this.poison(failure)
|
|
1430
|
+
reject(failure)
|
|
1431
|
+
return
|
|
1432
|
+
}
|
|
1433
|
+
resolve()
|
|
1434
|
+
})
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
const failure = new TransportError('malformed response', error)
|
|
1437
|
+
failure.writeFailure = true
|
|
1438
|
+
if (!this.closing) this.poison(failure)
|
|
1439
|
+
reject(failure)
|
|
1440
|
+
}
|
|
1441
|
+
}),
|
|
1442
|
+
)
|
|
1443
|
+
this.writeTail = next.then(() => undefined, () => undefined)
|
|
1444
|
+
return next
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Single-fire transport poison: fail all pending requests and the active
|
|
1449
|
+
* waiter, then start the unique graceful-first teardown.
|
|
1450
|
+
* @param {TransportError} error - the fatal transport error.
|
|
1451
|
+
* @returns {void}
|
|
1452
|
+
*/
|
|
1453
|
+
poison(error) {
|
|
1454
|
+
if (this.poisoned) return
|
|
1455
|
+
this.poisoned = true
|
|
1456
|
+
this.poisonError = error
|
|
1457
|
+
for (const [id, entry] of this.pending) {
|
|
1458
|
+
if (entry.signal !== undefined && entry.onAbort !== undefined) {
|
|
1459
|
+
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
1460
|
+
}
|
|
1461
|
+
entry.reject(error)
|
|
1462
|
+
}
|
|
1463
|
+
this.pending.clear()
|
|
1464
|
+
const waiter = this.waiter
|
|
1465
|
+
if (waiter !== null && !waiter.settled) {
|
|
1466
|
+
waiter.settled = true
|
|
1467
|
+
if (waiter.timer !== undefined) clearTimeout(waiter.timer)
|
|
1468
|
+
if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
|
|
1469
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort)
|
|
1470
|
+
}
|
|
1471
|
+
waiter.reject(error)
|
|
1472
|
+
}
|
|
1473
|
+
this.onFatal(this)
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Start the single-flight graceful-first teardown transaction.
|
|
1478
|
+
* @returns {Promise<void>}
|
|
1479
|
+
*/
|
|
1480
|
+
startTeardown() {
|
|
1481
|
+
if (this.teardownPromise !== undefined) return this.teardownPromise
|
|
1482
|
+
this.closing = true
|
|
1483
|
+
const closingError = new TransportError('server crashed', new Error('session teardown started'))
|
|
1484
|
+
for (const entry of this.pending.values()) {
|
|
1485
|
+
if (entry.signal !== undefined && entry.onAbort !== undefined) {
|
|
1486
|
+
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
1487
|
+
}
|
|
1488
|
+
entry.reject(closingError)
|
|
1489
|
+
}
|
|
1490
|
+
this.pending.clear()
|
|
1491
|
+
const waiter = this.waiter
|
|
1492
|
+
if (waiter !== null && !waiter.settled) {
|
|
1493
|
+
waiter.settled = true
|
|
1494
|
+
if (waiter.timer !== undefined) clearTimeout(waiter.timer)
|
|
1495
|
+
if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
|
|
1496
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort)
|
|
1497
|
+
}
|
|
1498
|
+
waiter.resolve({ kind: 'unavailable', reason: 'diagnostics unavailable' })
|
|
1499
|
+
}
|
|
1500
|
+
this.teardownPromise = this.teardown()
|
|
1501
|
+
// The teardown may be started fire-and-forget (poison, eviction); keep its
|
|
1502
|
+
// settlement observable but never let it surface as an unhandled rejection.
|
|
1503
|
+
void this.teardownPromise.catch(() => {})
|
|
1504
|
+
return this.teardownPromise
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* The unique teardown sequence: shutdown request within an independent
|
|
1509
|
+
* graceful budget, exit notification plus natural-close wait, whole-process-
|
|
1510
|
+
* tree liveness probe (bounded by the same budget), conditional
|
|
1511
|
+
* `terminate()` (the only hard-stop, exactly once, tree-scoped), then
|
|
1512
|
+
* `handle.done` and `waitForExit()`, and only then the process-lifetime
|
|
1513
|
+
* controller abort. Terminate is decided by whole-tree liveness, never by
|
|
1514
|
+
* direct-process `done` alone, so a root that exits while a helper remains
|
|
1515
|
+
* alive still escalates and the teardown cannot wait forever.
|
|
1516
|
+
* @returns {Promise<void>}
|
|
1517
|
+
*/
|
|
1518
|
+
async teardown() {
|
|
1519
|
+
/** @type {unknown[]} */
|
|
1520
|
+
const errors = []
|
|
1521
|
+
const budget = new AbortController()
|
|
1522
|
+
const budgetTimer = setTimeout(() => budget.abort(), this.config.shutdownTimeoutMs)
|
|
1523
|
+
let treeExited = false
|
|
1524
|
+
try {
|
|
1525
|
+
try {
|
|
1526
|
+
await this.request('shutdown', null, budget.signal, true)
|
|
1527
|
+
} catch {
|
|
1528
|
+
// settled or failed; the transaction continues either way
|
|
1529
|
+
}
|
|
1530
|
+
if (this.stdin !== undefined) {
|
|
1531
|
+
try {
|
|
1532
|
+
await abortable(
|
|
1533
|
+
this.write({ jsonrpc: '2.0', method: 'exit', params: null }),
|
|
1534
|
+
budget.signal,
|
|
1535
|
+
)
|
|
1536
|
+
} catch {
|
|
1537
|
+
// same transaction; no separate termination path
|
|
1538
|
+
}
|
|
1539
|
+
try {
|
|
1540
|
+
if (this.handle !== undefined) {
|
|
1541
|
+
await abortable(this.handle.done, budget.signal)
|
|
1542
|
+
}
|
|
1543
|
+
} catch {
|
|
1544
|
+
// natural close did not arrive inside the graceful budget
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// Whole-process-tree liveness within the remaining graceful budget: a
|
|
1548
|
+
// resolved `true` means the tree exited; a budget abort means it is
|
|
1549
|
+
// still alive and the tree-scoped terminate below is required.
|
|
1550
|
+
if (this.handle !== undefined) {
|
|
1551
|
+
try {
|
|
1552
|
+
treeExited = (await this.handle.waitForExit(budget.signal)) === true
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
if (error instanceof Error && error.name !== 'AbortError') errors.push(error)
|
|
1555
|
+
treeExited = false
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
} finally {
|
|
1559
|
+
clearTimeout(budgetTimer)
|
|
1560
|
+
}
|
|
1561
|
+
if (!treeExited && this.handle !== undefined) {
|
|
1562
|
+
try {
|
|
1563
|
+
this.handle.terminate()
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
errors.push(error)
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
if (this.handle !== undefined) {
|
|
1569
|
+
try {
|
|
1570
|
+
await this.handle.done
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
errors.push(error)
|
|
1573
|
+
}
|
|
1574
|
+
try {
|
|
1575
|
+
await this.handle.waitForExit()
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
errors.push(error)
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
// Own the protocol write quiescence: every server-request handler and the
|
|
1581
|
+
// serialized write tail must settle before cleanup returns, so no queued
|
|
1582
|
+
// frame (responses, shutdown, exit) can be written after dispose resolves.
|
|
1583
|
+
// No new server-request handlers start while closing, so this terminates.
|
|
1584
|
+
for (;;) {
|
|
1585
|
+
while (this.serverRequests.size > 0) {
|
|
1586
|
+
await Promise.allSettled([...this.serverRequests])
|
|
1587
|
+
}
|
|
1588
|
+
const tail = this.writeTail
|
|
1589
|
+
await tail
|
|
1590
|
+
if (this.writeTail === tail && this.serverRequests.size === 0) break
|
|
1591
|
+
}
|
|
1592
|
+
this.lifetimeController.abort()
|
|
1593
|
+
this.detachStreams()
|
|
1594
|
+
if (errors.length === 1) throw errors[0]
|
|
1595
|
+
if (errors.length > 1) {
|
|
1596
|
+
throw new AggregateError(errors, '@banbolee/dsh-lsp-diagnostics session teardown failed')
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* Detach stdout/stdin listeners after teardown quiescence.
|
|
1602
|
+
* @returns {void}
|
|
1603
|
+
*/
|
|
1604
|
+
detachStreams() {
|
|
1605
|
+
if (this.stdout !== undefined) {
|
|
1606
|
+
this.stdout.removeListener('data', this.onStdoutData)
|
|
1607
|
+
this.stdout.removeListener('error', this.onStdoutError)
|
|
1608
|
+
}
|
|
1609
|
+
if (this.stdin !== undefined && typeof this.stdin.removeListener === 'function') {
|
|
1610
|
+
this.stdin.removeListener('error', this.onStdinError)
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|