@akira-tl/forgerelay 0.4.4 → 0.4.6

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 CHANGED
@@ -4,6 +4,32 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.6] - 2026-08-11
8
+
9
+ ### Added
10
+
11
+ - Added published `npm run lsp:interop` acceptance for `typescript-language-server`, Pyright, `rust-analyzer`, `gopls`, and `clangd`: detected executables run through built-in discovery and real stdio LSP, while missing external servers are reported as explicit skips without automatic installation.
12
+ - Extended the real 7677 HTTP/OAuth/MCP acceptance path to execute all six `code.intelligence` v1 operations, normalized results, a stable error path, and Language-service `shutdown -> exit` behavior while keeping exactly nine Core MCP tools.
13
+
14
+ ### Changed
15
+
16
+ - Cloud release CI now runs optional real-server interoperability after the normal deterministic fake-LSP test/build gate on Linux, macOS, and Windows. Executable preflight now distinguishes a runnable Language server from PATH shims/proxies that exist but fail to launch, so incomplete rustup components are skipped rather than misreported as installed servers.
17
+ - Local `release:verify` now includes an isolated Node 22.19.0 parity sandbox with its own `npm ci` and focused LSP/release regression suite; cloud CI and publication use the same Node 22.19.0 runtime to reduce local/cloud drift, while Windows test cleanup retries transient locked-directory removal.
18
+ - Completed the 0.4 LSP v1 user, configuration, roadmap, contributor, and explicit Language-server example documentation, and hardened cross-platform/concurrency acceptance timing exposed during final release validation.
19
+
20
+ ## [0.4.5] - 2026-08-11
21
+
22
+ ### Added
23
+
24
+ - Added Host-to-LSP cancellation propagation, bounded per-service semantic concurrency/queueing, and stable request timeout/cancellation/capacity errors without exposing arbitrary Agent-controlled timeout values.
25
+ - Added aggregate Language-service runtime telemetry for service/process/request/document/diagnostic/stderr retention without logging source contents or paths.
26
+
27
+ ### Changed
28
+
29
+ - Language services now retry one unexpected server crash, enter a bounded cooldown after repeated crashes, and invalidate only affected services when the effective server-definition fingerprint changes.
30
+ - Language-service lifecycle now distinguishes truly idle services from cancellation-ignoring requests, evicts only the least-recently-used safe idle service at capacity, shares one service across logical workspaces on the same physical project, and releases managed-worktree services before finalization.
31
+ - Idle shutdown and repeated open/query/config/crash cycles now explicitly release and bound server processes, synchronized documents, diagnostic snapshots, request state, stderr tails, and service counts.
32
+
7
33
  ## [0.4.4] - 2026-08-11
8
34
 
9
35
  ### Added
package/README.md CHANGED
@@ -112,6 +112,22 @@ reports a semantic capability but the Host still shows an older tool snapshot,
112
112
  refresh or reconnect the integration rather than assuming the capability is
113
113
  missing from ForgeRelay.
114
114
 
115
+ ## LSP code intelligence
116
+
117
+ ForgeRelay 0.4 LSP v1 exposes semantic code navigation through the
118
+ `code.intelligence` Capability without adding language-specific top-level MCP tools.
119
+ The v1 operations are definition, hover/type information, references, document
120
+ symbols, workspace symbols, and diagnostics. Results use ForgeRelay-owned normalized
121
+ locations, ranges, symbols, hover content, and diagnostic shapes rather than raw LSP
122
+ wire unions.
123
+
124
+ Language servers remain external dependencies. ForgeRelay can discover
125
+ `typescript-language-server`, `pyright-langserver`, `rust-analyzer`, `gopls`, and
126
+ `clangd` when they already exist on `PATH`, or use structured project/global
127
+ configuration, but it never installs a server automatically. See
128
+ [Configuration Reference](docs/configuration.md#lsp-code-intelligence) and
129
+ [`examples/language-servers.json`](examples/language-servers.json).
130
+
115
131
  ## Worktrees without the usual cleanup mess
116
132
 
117
133
  A new managed worktree gets its own `forgerelay/*` branch instead of a detached
@@ -247,13 +263,13 @@ forgerelay doctor
247
263
 
248
264
  ## Where ForgeRelay is going
249
265
 
250
- The next additions are focused on making the local execution layer more useful,
251
- not on turning ForgeRelay into another all-in-one agent framework:
266
+ With LSP code intelligence established in the 0.4 line, the next additions remain
267
+ focused on making the local execution layer more useful, not on turning ForgeRelay
268
+ into another all-in-one agent framework:
252
269
 
253
- 1. LSP-backed code intelligence;
254
- 2. first-class MCP subagent delegation;
255
- 3. stronger worktree verification and recovery;
256
- 4. checkpoint/rewind and retention improvements.
270
+ 1. first-class MCP subagent delegation;
271
+ 2. stronger worktree verification and recovery;
272
+ 3. checkpoint/rewind and retention improvements.
257
273
 
258
274
  ForgeRelay does not plan to add its own shell sandbox, long-term memory system,
259
275
  or plugin marketplace. Conversation, planning, web access, and other host-native
@@ -277,10 +293,13 @@ npm run release:verify
277
293
  ```
278
294
 
279
295
  Daily branch pushes do not run cloud CI. When preparing a release, run the full
280
- local release verification first. Pushing a matching `vX.Y.Z` tag to
281
- `Akira-TL/forgerelay` is the only cloud CI and publish trigger: GitHub Actions
282
- runs the reusable multi-platform CI, then publishes `@akira-tl/forgerelay` and
283
- creates the matching GitHub Release only after CI succeeds.
296
+ local release verification first. `release:verify` includes a focused parity pass
297
+ in an isolated Node 22.19.0 environment with its own `npm ci`, matching the cloud
298
+ CI runtime for native addons and high-risk LSP lifecycle tests. Pushing a matching
299
+ `vX.Y.Z` tag to `Akira-TL/forgerelay` is the only cloud CI and publish trigger:
300
+ GitHub Actions runs the reusable multi-platform CI, then publishes
301
+ `@akira-tl/forgerelay` and creates the matching GitHub Release only after CI
302
+ succeeds.
284
303
 
285
304
  See [Versioning and Release Management](docs/versioning.md) for the bootstrap and
286
305
  Trusted Publishing setup.
@@ -4,7 +4,7 @@ Use the `code.intelligence` Capability for read-only semantic code navigation ba
4
4
 
5
5
  ForgeRelay does not install Language servers. It discovers supported executables when available and accepts explicit definitions from the global ForgeRelay config or `<workspace>/.forgerelay/language-servers.json`. Project definitions override global definitions, and global definitions override built-in discovery. An explicit definition may disable discovery with `enabled: false`.
6
6
 
7
- ForgeRelay 0.4.4 adds `diagnostics` alongside `definition`, `hover`, `references`, `documentSymbols`, and `workspaceSymbols`. Position-based operations accept a workspace-relative source `path` plus 1-based `line` and `column` values. `documentSymbols` needs only `path` and an optional bounded `limit`. `workspaceSymbols` uses `path` to select the Language project/service and accepts a `query` plus optional `limit`; it does not merge multiple nested Language services. `diagnostics` uses `path` plus an optional bounded `limit`. Columns are Unicode code-point positions; ForgeRelay converts them to the position encoding negotiated with the Language server.
7
+ ForgeRelay 0.4 LSP v1 exposes `definition`, `hover`, `references`, `documentSymbols`, `workspaceSymbols`, and `diagnostics`. Position-based operations accept a workspace-relative source `path` plus 1-based `line` and `column` values. `documentSymbols` needs only `path` and an optional bounded `limit`. `workspaceSymbols` uses `path` to select the Language project/service and accepts a `query` plus optional `limit`; it does not merge multiple nested Language services. `diagnostics` uses `path` plus an optional bounded `limit`. Columns are Unicode code-point positions; ForgeRelay converts them to the position encoding negotiated with the Language server.
8
8
 
9
9
  Code-intelligence results use ForgeRelay-owned shapes rather than raw LSP wire types. Definition returns normalized locations. Hover returns one `contents` string, an optional legacy `language`, and an optional ForgeRelay-normalized `range`; plaintext, Markdown `MarkupContent`, and supported legacy `MarkedString` payloads are normalized before reaching the Agent. References uses the same normalized location shape, defaults to `limit: 100`, and accepts limits up to 1000. Its result reports `returned`, `truncated`, and `total` when the complete Language-server response makes the total known.
10
10
 
@@ -15,3 +15,11 @@ Diagnostics use the same normalized result contract for push and pull providers.
15
15
  Semantic locations may identify External code locations outside the Workspace, but that does not expand ForgeRelay's allowed roots or grant the file tools permission to read those paths.
16
16
 
17
17
  Language-server definitions use structured process configuration rather than shell command strings. A project configuration entry may contain `command`, `args`, `env`, `languages`, `extensions`, `languageIdByExtension`, `projectMarkers`, and `enabled` fields. Use `languageIdByExtension` when one server definition covers multiple language IDs whose extensions do not map one-to-one by array position. The server command is launched directly without a shell.
18
+
19
+ Semantic Language-server requests use an internal bounded deadline; the Agent contract does not expose arbitrary timeout controls. Host cancellation is propagated to in-flight LSP requests, and ForgeRelay bounds both per-service semantic concurrency and queued work. Document synchronization is serialized before semantic requests cross the concurrency gate, while independent semantic reads may execute concurrently after that barrier. A server that ignores cancellation keeps its underlying request slot occupied until it actually settles, preventing repeated cancellations from creating an unbounded set of pending JSON-RPC requests.
20
+
21
+ An unexpected Language-server process exit fails its pending JSON-RPC work immediately instead of waiting for the semantic deadline. ForgeRelay discards that service and retries the current semantic operation at most once. A second consecutive crash for the same Language project/server-definition fingerprint enters a short cooldown before another process may be started; a successful retry clears the crash state. Effective server definitions are fingerprinted, so a changed project/global/built-in definition selects a new service identity on the next code-intelligence resolution and retires only an idle service with the same project root/server id but the old fingerprint. ForgeRelay does not add a recursive config watcher.
22
+
23
+ Language services are shared by physical Language project identity rather than logical Workspace id, so multiple conversations over the same checkout do not multiply server processes. Idle services are reclaimed after a bounded TTL, and the global service cap evicts the least-recently-used truly idle service; a request that has returned to the Host but whose server ignored cancellation still counts as active until the underlying LSP request settles. Managed-worktree finalization releases idle Language services rooted in that worktree before removing it and refuses finalization while semantic work is still active. Debug runtime telemetry reports only aggregate service/process/request/document/diagnostic/stderr counts and never source contents.
24
+
25
+ The built-in discovery candidates are `typescript-language-server`, `pyright-langserver`, `rust-analyzer`, `gopls`, and `clangd`. Contributors can run `npm run lsp:interop` to exercise any of those executables that are already present on `PATH`; each detected server is tested through built-in discovery and real stdio LSP, while absent servers are reported as explicit skips. The interoperability check never downloads or installs a Language server.
@@ -79,7 +79,7 @@ export class CapabilityRegistry {
79
79
  throw new CapabilityError("invalid_arguments", `Invalid arguments for capability ${name}: ${details}`);
80
80
  }
81
81
  try {
82
- return await definition.run(parsed.data, context);
82
+ return await definition.run(parsed.data, context, options);
83
83
  }
84
84
  catch (error) {
85
85
  if (error instanceof CapabilityError)
@@ -186,7 +186,7 @@ export function createCapabilityRegistry(dependencies) {
186
186
  available: dependencies.codeIntelligence?.available ?? false,
187
187
  reason: dependencies.codeIntelligence?.unavailableReason,
188
188
  }),
189
- run: async (input, context) => dependencies.codeIntelligence.run(input, context),
189
+ run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
190
190
  }]
191
191
  : []),
192
192
  ...(dependencies.downloadArtifact
package/dist/logger.js CHANGED
@@ -223,7 +223,17 @@ function formatRuntimeResources(entry) {
223
223
  const completed = numberField(entry.processesCompleted) ?? 0;
224
224
  const workspaces = numberField(entry.cachedWorkspaces) ?? 0;
225
225
  const reviewStates = numberField(entry.reviewStates) ?? 0;
226
- return `runtime rss=${rssMb}MB heap=${heapUsedMb}/${heapTotalMb}MB transports=${transports} processes=${running} running/${completed} completed workspaces=${workspaces} review=${reviewStates}`;
226
+ const languageServices = numberField(entry.languageServices) ?? 0;
227
+ const languageServicesActive = numberField(entry.languageServicesActive) ?? 0;
228
+ const languageProcesses = numberField(entry.languageProcessesRunning) ?? 0;
229
+ const languageRequestsActive = numberField(entry.languageRequestsActive) ?? 0;
230
+ const languageRequestsQueued = numberField(entry.languageRequestsQueued) ?? 0;
231
+ const languageDocuments = numberField(entry.languageOpenDocuments) ?? 0;
232
+ const languageDiagnosticSnapshots = numberField(entry.languageDiagnosticSnapshots) ?? 0;
233
+ const languageDiagnosticsRetained = numberField(entry.languageDiagnosticsRetained) ?? 0;
234
+ const languageStderrBytes = numberField(entry.languageStderrBytes) ?? 0;
235
+ const languageCooldowns = numberField(entry.languageCrashCooldowns) ?? 0;
236
+ return `runtime rss=${rssMb}MB heap=${heapUsedMb}/${heapTotalMb}MB transports=${transports} processes=${running} running/${completed} completed workspaces=${workspaces} review=${reviewStates} lsp=${languageServices} services/${languageServicesActive} active/${languageProcesses} processes requests=${languageRequestsActive} active/${languageRequestsQueued} queued docs=${languageDocuments} diagnostics=${languageDiagnosticSnapshots} snapshots/${languageDiagnosticsRetained} retained stderr=${languageStderrBytes}B cooldowns=${languageCooldowns}`;
227
237
  }
228
238
  function bytesToMegabytes(value) {
229
239
  return value === undefined ? 0 : Math.round(value / (1024 * 1024));
@@ -3,7 +3,7 @@ import { readFile, realpath } from "node:fs/promises";
3
3
  import { basename, resolve } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { createMessageConnection } from "vscode-jsonrpc/node";
6
- import { DefinitionRequest, DocumentDiagnosticReportKind, DocumentDiagnosticRequest, DocumentSymbolRequest, DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, ExitNotification, HoverRequest, InitializeRequest, ReferencesRequest, InitializedNotification, MarkupKind, PositionEncodingKind, PublishDiagnosticsNotification, ShutdownRequest, TextDocumentSyncKind, WorkspaceSymbolRequest, } from "vscode-languageserver-protocol";
6
+ import { DefinitionRequest, DocumentDiagnosticReportKind, DocumentDiagnosticRequest, DocumentSymbolRequest, ExitNotification, HoverRequest, InitializeRequest, ReferencesRequest, InitializedNotification, MarkupKind, PositionEncodingKind, PublishDiagnosticsNotification, ShutdownRequest, WorkspaceSymbolRequest, } from "vscode-languageserver-protocol";
7
7
  import { LanguageServerConfigurationError, } from "./language-server-config.js";
8
8
  import { terminateProcessTree } from "../process-platform.js";
9
9
  import { CodeIntelligenceError } from "./code-intelligence-error.js";
@@ -11,8 +11,10 @@ import { DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT } from "./code-intelligence-type
11
11
  import { normalizeHoverContents } from "./normalization/hover.js";
12
12
  import { normalizeDocumentSymbols, normalizeWorkspaceSymbols, } from "./normalization/symbols.js";
13
13
  import { isWithin, locationEntries, normalizeLocations, workspaceDisplayPath, } from "./normalization/locations.js";
14
- import { lspPositionFromUser, rangeFromLsp, wholeDocumentRange } from "./position-encoding.js";
14
+ import { lspPositionFromUser, rangeFromLsp } from "./position-encoding.js";
15
15
  import { DiagnosticSnapshotStore } from "./runtime/diagnostic-snapshots.js";
16
+ import { DocumentSynchronizer } from "./runtime/document-synchronizer.js";
17
+ import { SemanticRequestCoordinator } from "./runtime/semantic-requests.js";
16
18
  export { CodeIntelligenceError } from "./code-intelligence-error.js";
17
19
  const STDERR_TAIL_BYTES = 64 * 1024;
18
20
  export class LanguageService {
@@ -27,8 +29,9 @@ export class LanguageService {
27
29
  initializePromise;
28
30
  positionEncoding = PositionEncodingKind.UTF16;
29
31
  capabilities;
30
- documents = new Map();
32
+ documents;
31
33
  diagnosticSnapshots;
34
+ semanticRequests;
32
35
  stderrTail = Buffer.alloc(0);
33
36
  closed = false;
34
37
  constructor(workspaceRoot, project, policy) {
@@ -36,7 +39,13 @@ export class LanguageService {
36
39
  this.project = project;
37
40
  this.policy = policy;
38
41
  this.key = languageServiceKey(project);
42
+ this.documents = new DocumentSynchronizer(project.definition);
39
43
  this.diagnosticSnapshots = new DiagnosticSnapshotStore(policy.maxDiagnosticDocuments, policy.maxDiagnosticsPerDocument);
44
+ this.semanticRequests = new SemanticRequestCoordinator({
45
+ maxConcurrent: policy.maxConcurrentSemanticRequests,
46
+ maxQueued: policy.maxQueuedSemanticRequests,
47
+ deadlineMs: policy.requestTimeoutMs,
48
+ });
40
49
  }
41
50
  acquire() {
42
51
  this.inFlight += 1;
@@ -46,19 +55,37 @@ export class LanguageService {
46
55
  this.inFlight = Math.max(0, this.inFlight - 1);
47
56
  this.lastUsedAt = Date.now();
48
57
  }
49
- async definition(input) {
58
+ get isIdle() {
59
+ return this.inFlight === 0 &&
60
+ this.semanticRequests.activeCount === 0 &&
61
+ this.semanticRequests.queuedCount === 0;
62
+ }
63
+ get runtimeStats() {
64
+ const child = this.child;
65
+ return {
66
+ operationInFlight: this.inFlight,
67
+ semanticRequestsActive: this.semanticRequests.activeCount,
68
+ semanticRequestsQueued: this.semanticRequests.queuedCount,
69
+ openDocuments: this.documents.size,
70
+ diagnosticSnapshots: this.diagnosticSnapshots.size,
71
+ diagnosticsRetained: this.diagnosticSnapshots.retainedDiagnostics,
72
+ stderrBytes: this.stderrTail.length,
73
+ processRunning: Boolean(child && child.exitCode === null && child.signalCode === null),
74
+ };
75
+ }
76
+ async definition(input, signal) {
50
77
  try {
51
78
  await this.ensureStarted();
52
79
  if (!this.capabilities?.definitionProvider) {
53
80
  throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise definition support.`);
54
81
  }
55
82
  const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
56
- const document = await this.syncDocument(sourcePath);
83
+ const document = await this.syncDocumentOrdered(sourcePath);
57
84
  const position = lspPositionFromUser(document.text, input.line, input.column, this.positionEncoding);
58
- const response = await withTimeout(this.connection.sendRequest(DefinitionRequest.type, {
85
+ const response = await this.semanticRequests.run(`Definition request for ${input.path}`, signal, (token) => this.connection.sendRequest(DefinitionRequest.type, {
59
86
  textDocument: { uri: document.uri },
60
87
  position,
61
- }), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Definition request timed out for ${input.path}.`));
88
+ }, token));
62
89
  const locations = await normalizeLocations(locationEntries(response), this.workspaceRoot, this.positionEncoding);
63
90
  return {
64
91
  operation: "definition",
@@ -76,19 +103,19 @@ export class LanguageService {
76
103
  throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
77
104
  }
78
105
  }
79
- async hover(input) {
106
+ async hover(input, signal) {
80
107
  try {
81
108
  await this.ensureStarted();
82
109
  if (!this.capabilities?.hoverProvider) {
83
110
  throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise hover support.`);
84
111
  }
85
112
  const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
86
- const document = await this.syncDocument(sourcePath);
113
+ const document = await this.syncDocumentOrdered(sourcePath);
87
114
  const position = lspPositionFromUser(document.text, input.line, input.column, this.positionEncoding);
88
- const response = await withTimeout(this.connection.sendRequest(HoverRequest.type, {
115
+ const response = await this.semanticRequests.run(`Hover request for ${input.path}`, signal, (token) => this.connection.sendRequest(HoverRequest.type, {
89
116
  textDocument: { uri: document.uri },
90
117
  position,
91
- }), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Hover request timed out for ${input.path}.`));
118
+ }, token));
92
119
  const common = {
93
120
  operation: "hover",
94
121
  selectedServer: this.project.definition.id,
@@ -114,20 +141,20 @@ export class LanguageService {
114
141
  throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
115
142
  }
116
143
  }
117
- async references(input) {
144
+ async references(input, signal) {
118
145
  try {
119
146
  await this.ensureStarted();
120
147
  if (!this.capabilities?.referencesProvider) {
121
148
  throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise references support.`);
122
149
  }
123
150
  const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
124
- const document = await this.syncDocument(sourcePath);
151
+ const document = await this.syncDocumentOrdered(sourcePath);
125
152
  const position = lspPositionFromUser(document.text, input.line, input.column, this.positionEncoding);
126
- const response = await withTimeout(this.connection.sendRequest(ReferencesRequest.type, {
153
+ const response = await this.semanticRequests.run(`References request for ${input.path}`, signal, (token) => this.connection.sendRequest(ReferencesRequest.type, {
127
154
  textDocument: { uri: document.uri },
128
155
  position,
129
156
  context: { includeDeclaration: true },
130
- }), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `References request timed out for ${input.path}.`));
157
+ }, token));
131
158
  const entries = locationEntries(response ?? []);
132
159
  const limit = input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT;
133
160
  const selected = entries.slice(0, limit);
@@ -151,17 +178,17 @@ export class LanguageService {
151
178
  throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
152
179
  }
153
180
  }
154
- async documentSymbols(input) {
181
+ async documentSymbols(input, signal) {
155
182
  try {
156
183
  await this.ensureStarted();
157
184
  if (!this.capabilities?.documentSymbolProvider) {
158
185
  throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise document-symbol support.`);
159
186
  }
160
187
  const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
161
- const document = await this.syncDocument(sourcePath);
162
- const response = await withTimeout(this.connection.sendRequest(DocumentSymbolRequest.type, {
188
+ const document = await this.syncDocumentOrdered(sourcePath);
189
+ const response = await this.semanticRequests.run(`Document-symbol request for ${input.path}`, signal, (token) => this.connection.sendRequest(DocumentSymbolRequest.type, {
163
190
  textDocument: { uri: document.uri },
164
- }), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Document-symbol request timed out for ${input.path}.`));
191
+ }, token));
165
192
  const normalized = normalizeDocumentSymbols(response, document.text, this.positionEncoding, input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT);
166
193
  return {
167
194
  operation: "documentSymbols",
@@ -176,15 +203,15 @@ export class LanguageService {
176
203
  throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
177
204
  }
178
205
  }
179
- async workspaceSymbols(input) {
206
+ async workspaceSymbols(input, signal) {
180
207
  try {
181
208
  await this.ensureStarted();
182
209
  if (!this.capabilities?.workspaceSymbolProvider) {
183
210
  throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise workspace-symbol support.`);
184
211
  }
185
212
  const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
186
- await this.syncDocument(sourcePath);
187
- const response = await withTimeout(this.connection.sendRequest(WorkspaceSymbolRequest.type, { query: input.query }), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Workspace-symbol request timed out for ${JSON.stringify(input.query)}.`));
213
+ await this.syncDocumentOrdered(sourcePath);
214
+ const response = await this.semanticRequests.run(`Workspace-symbol request for ${JSON.stringify(input.query)}`, signal, (token) => this.connection.sendRequest(WorkspaceSymbolRequest.type, { query: input.query }, token));
188
215
  const normalized = await normalizeWorkspaceSymbols(response, this.workspaceRoot, this.positionEncoding, input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT);
189
216
  return {
190
217
  operation: "workspaceSymbols",
@@ -199,11 +226,11 @@ export class LanguageService {
199
226
  throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
200
227
  }
201
228
  }
202
- async diagnostics(input) {
229
+ async diagnostics(input, signal) {
203
230
  try {
204
231
  await this.ensureStarted();
205
232
  const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
206
- const document = await this.syncDocument(sourcePath);
233
+ const document = await this.syncDocumentOrdered(sourcePath);
207
234
  const limit = input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT;
208
235
  const common = {
209
236
  operation: "diagnostics",
@@ -214,11 +241,11 @@ export class LanguageService {
214
241
  const diagnosticProvider = this.capabilities?.diagnosticProvider;
215
242
  if (diagnosticProvider) {
216
243
  const previousResultId = this.diagnosticSnapshots.previousPullResultId(document.uri);
217
- const response = await withTimeout(this.connection.sendRequest(DocumentDiagnosticRequest.type, {
244
+ const response = await this.semanticRequests.run(`Diagnostic request for ${input.path}`, signal, (token) => this.connection.sendRequest(DocumentDiagnosticRequest.type, {
218
245
  textDocument: { uri: document.uri },
219
246
  ...(diagnosticProvider.identifier === undefined ? {} : { identifier: diagnosticProvider.identifier }),
220
247
  ...(previousResultId === undefined ? {} : { previousResultId }),
221
- }), this.policy.requestTimeoutMs, () => new CodeIntelligenceError("code.request_timeout", `Diagnostic request timed out for ${input.path}.`));
248
+ }, token));
222
249
  if (response.kind === DocumentDiagnosticReportKind.Full) {
223
250
  this.diagnosticSnapshots.capturePull(response.items, document, this.positionEncoding, response.resultId);
224
251
  }
@@ -254,18 +281,7 @@ export class LanguageService {
254
281
  const connection = this.connection;
255
282
  const child = this.child;
256
283
  if (connection) {
257
- for (const document of this.documents.values()) {
258
- if (!document.openNotified)
259
- continue;
260
- try {
261
- await connection.sendNotification(DidCloseTextDocumentNotification.type, {
262
- textDocument: { uri: document.uri },
263
- });
264
- }
265
- catch {
266
- // The server may already be gone.
267
- }
268
- }
284
+ await this.documents.closeAll(connection);
269
285
  try {
270
286
  await withTimeout(connection.sendRequest(ShutdownRequest.type), this.policy.shutdownTimeoutMs, () => new Error("Language-server shutdown timed out."));
271
287
  await connection.sendNotification(ExitNotification.type);
@@ -286,6 +302,9 @@ export class LanguageService {
286
302
  this.child = undefined;
287
303
  this.connection = undefined;
288
304
  this.initializePromise = undefined;
305
+ this.capabilities = undefined;
306
+ this.positionEncoding = PositionEncodingKind.UTF16;
307
+ this.stderrTail = Buffer.alloc(0);
289
308
  }
290
309
  async ensureStarted() {
291
310
  if (this.closed) {
@@ -335,6 +354,16 @@ export class LanguageService {
335
354
  onExitDuringInitialization = (code, signal) => reject(new CodeIntelligenceError("code.language_service_start_failed", `Language server ${definition.id} exited during initialization (${signal ?? code ?? "unknown"}).${this.stderrSuffix()}`));
336
355
  child.once("exit", onExitDuringInitialization);
337
356
  });
357
+ child.on("exit", () => {
358
+ if (this.closed || this.child !== child)
359
+ return;
360
+ try {
361
+ connection.dispose();
362
+ }
363
+ catch {
364
+ // Pending semantic requests will surface the unexpected process exit.
365
+ }
366
+ });
338
367
  const rootUri = pathToFileURL(this.project.projectRoot).href;
339
368
  const initializeParams = {
340
369
  processId: process.pid,
@@ -418,49 +447,8 @@ export class LanguageService {
418
447
  this.diagnosticSnapshots.capturePush(params, this.documents.get(params.uri), this.positionEncoding);
419
448
  });
420
449
  }
421
- async syncDocument(sourcePath) {
422
- const uri = pathToFileURL(sourcePath).href;
423
- const text = await readFile(sourcePath, "utf8");
424
- const existing = this.documents.get(uri);
425
- const languageId = languageIdForPath(this.project.definition, sourcePath);
426
- const synchronization = textDocumentSynchronization(this.capabilities?.textDocumentSync);
427
- if (!existing) {
428
- const document = { uri, languageId, version: 1, text, openNotified: false };
429
- this.documents.set(uri, document);
430
- if (synchronization.openClose) {
431
- await this.connection.sendNotification(DidOpenTextDocumentNotification.type, {
432
- textDocument: {
433
- uri: document.uri,
434
- languageId: document.languageId,
435
- version: document.version,
436
- text: document.text,
437
- },
438
- });
439
- document.openNotified = true;
440
- }
441
- return document;
442
- }
443
- if (existing.text !== text) {
444
- const previousText = existing.text;
445
- existing.version += 1;
446
- existing.text = text;
447
- if (synchronization.change === TextDocumentSyncKind.Full) {
448
- await this.connection.sendNotification(DidChangeTextDocumentNotification.type, {
449
- textDocument: { uri, version: existing.version },
450
- contentChanges: [{ text }],
451
- });
452
- }
453
- else if (synchronization.change === TextDocumentSyncKind.Incremental) {
454
- await this.connection.sendNotification(DidChangeTextDocumentNotification.type, {
455
- textDocument: { uri, version: existing.version },
456
- contentChanges: [{
457
- range: wholeDocumentRange(previousText, this.positionEncoding),
458
- text,
459
- }],
460
- });
461
- }
462
- }
463
- return existing;
450
+ async syncDocumentOrdered(sourcePath) {
451
+ return this.documents.sync(sourcePath, this.connection, this.capabilities?.textDocumentSync, this.positionEncoding);
464
452
  }
465
453
  appendStderr(chunk) {
466
454
  this.stderrTail = Buffer.concat([this.stderrTail, chunk]);
@@ -480,22 +468,6 @@ export function languageServiceKey(project) {
480
468
  project.definition.fingerprint,
481
469
  ]);
482
470
  }
483
- function languageIdForPath(definition, path) {
484
- const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
485
- return definition.languageIdByExtension[extension] ?? definition.languages[0];
486
- }
487
- function textDocumentSynchronization(value) {
488
- if (typeof value === "number") {
489
- return {
490
- openClose: value !== TextDocumentSyncKind.None,
491
- change: value,
492
- };
493
- }
494
- return {
495
- openClose: value?.openClose === true,
496
- change: value?.change ?? TextDocumentSyncKind.None,
497
- };
498
- }
499
471
  async function workspaceSourcePath(workspaceRoot, inputPath) {
500
472
  const root = resolve(workspaceRoot);
501
473
  const path = resolve(root, inputPath);
@@ -52,6 +52,14 @@ export class DiagnosticSnapshotStore {
52
52
  get size() {
53
53
  return this.pushSnapshots.size + this.pullSnapshots.size;
54
54
  }
55
+ get retainedDiagnostics() {
56
+ let total = 0;
57
+ for (const snapshot of this.pushSnapshots.values())
58
+ total += snapshot.diagnostics.length;
59
+ for (const snapshot of this.pullSnapshots.values())
60
+ total += snapshot.diagnostics.length;
61
+ return total;
62
+ }
55
63
  normalizeSnapshot(diagnostics, document, encoding, metadata) {
56
64
  return {
57
65
  diagnostics: diagnostics
@@ -0,0 +1,102 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { pathToFileURL } from "node:url";
3
+ import { DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, TextDocumentSyncKind, } from "vscode-languageserver-protocol";
4
+ import { wholeDocumentRange } from "../position-encoding.js";
5
+ export class DocumentSynchronizer {
6
+ definition;
7
+ documents = new Map();
8
+ queue = Promise.resolve();
9
+ constructor(definition) {
10
+ this.definition = definition;
11
+ }
12
+ get(uri) {
13
+ return this.documents.get(uri);
14
+ }
15
+ get size() {
16
+ return this.documents.size;
17
+ }
18
+ async sync(sourcePath, connection, synchronizationValue, positionEncoding) {
19
+ const operation = this.queue.then(() => this.syncNow(sourcePath, connection, synchronizationValue, positionEncoding), () => this.syncNow(sourcePath, connection, synchronizationValue, positionEncoding));
20
+ this.queue = operation.then(() => undefined, () => undefined);
21
+ return operation;
22
+ }
23
+ async closeAll(connection) {
24
+ await this.queue;
25
+ for (const document of this.documents.values()) {
26
+ if (!document.openNotified)
27
+ continue;
28
+ try {
29
+ await connection.sendNotification(DidCloseTextDocumentNotification.type, {
30
+ textDocument: { uri: document.uri },
31
+ });
32
+ }
33
+ catch {
34
+ // The server may already be gone.
35
+ }
36
+ }
37
+ }
38
+ clear() {
39
+ this.documents.clear();
40
+ this.queue = Promise.resolve();
41
+ }
42
+ async syncNow(sourcePath, connection, synchronizationValue, positionEncoding) {
43
+ const uri = pathToFileURL(sourcePath).href;
44
+ const text = await readFile(sourcePath, "utf8");
45
+ const existing = this.documents.get(uri);
46
+ const languageId = languageIdForPath(this.definition, sourcePath);
47
+ const synchronization = textDocumentSynchronization(synchronizationValue);
48
+ if (!existing) {
49
+ const document = { uri, languageId, version: 1, text, openNotified: false };
50
+ this.documents.set(uri, document);
51
+ if (synchronization.openClose) {
52
+ await connection.sendNotification(DidOpenTextDocumentNotification.type, {
53
+ textDocument: {
54
+ uri: document.uri,
55
+ languageId: document.languageId,
56
+ version: document.version,
57
+ text: document.text,
58
+ },
59
+ });
60
+ document.openNotified = true;
61
+ }
62
+ return document;
63
+ }
64
+ if (existing.text !== text) {
65
+ const previousText = existing.text;
66
+ existing.version += 1;
67
+ existing.text = text;
68
+ if (synchronization.change === TextDocumentSyncKind.Full) {
69
+ await connection.sendNotification(DidChangeTextDocumentNotification.type, {
70
+ textDocument: { uri, version: existing.version },
71
+ contentChanges: [{ text }],
72
+ });
73
+ }
74
+ else if (synchronization.change === TextDocumentSyncKind.Incremental) {
75
+ await connection.sendNotification(DidChangeTextDocumentNotification.type, {
76
+ textDocument: { uri, version: existing.version },
77
+ contentChanges: [{
78
+ range: wholeDocumentRange(previousText, positionEncoding),
79
+ text,
80
+ }],
81
+ });
82
+ }
83
+ }
84
+ return existing;
85
+ }
86
+ }
87
+ function languageIdForPath(definition, path) {
88
+ const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
89
+ return definition.languageIdByExtension[extension] ?? definition.languages[0];
90
+ }
91
+ function textDocumentSynchronization(value) {
92
+ if (typeof value === "number") {
93
+ return {
94
+ openClose: value !== TextDocumentSyncKind.None,
95
+ change: value,
96
+ };
97
+ }
98
+ return {
99
+ openClose: value?.openClose === true,
100
+ change: value?.change ?? TextDocumentSyncKind.None,
101
+ };
102
+ }