pi-lens 3.8.73 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2614 -12
- package/README.md +50 -21
- package/dist/clients/actionable-warnings.js +73 -18
- package/dist/clients/agent-behavior-client.js +21 -4
- package/dist/clients/ast-grep-client.js +68 -16
- package/dist/clients/ast-grep-types.js +0 -1
- package/dist/clients/ast-grep-yaml-synth.js +37 -10
- package/dist/clients/atomic-write-staging.js +63 -0
- package/dist/clients/atomic-write.js +116 -16
- package/dist/clients/bash-file-access.js +193 -23
- package/dist/clients/biome-client.js +29 -25
- package/dist/clients/bootstrap.js +11 -2
- package/dist/clients/bounded-cache.js +34 -0
- package/dist/clients/bounded-pid-file-lock.js +192 -0
- package/dist/clients/bus-events-logger.js +5 -1
- package/dist/clients/bus-publish.js +10 -3
- package/dist/clients/cache/rule-cache.js +89 -6
- package/dist/clients/cache-manager.js +97 -9
- package/dist/clients/cache-observability.js +498 -0
- package/dist/clients/call-graph.js +460 -108
- package/dist/clients/cascade-logger.js +5 -1
- package/dist/clients/child-unref.js +85 -0
- package/dist/clients/codebase-model.js +52 -23
- package/dist/clients/collateral-test-role.js +40 -0
- package/dist/clients/complexity-client.js +6 -8
- package/dist/clients/console-guard-install.js +14 -0
- package/dist/clients/cooperative-budget.js +42 -0
- package/dist/clients/dead-code-client.js +2 -1
- package/dist/clients/debug-handles.js +206 -0
- package/dist/clients/debug-heap.js +167 -0
- package/dist/clients/degradation-ledger.js +153 -0
- package/dist/clients/dependency-checker.js +466 -63
- package/dist/clients/diagnostic-dispositions.js +83 -13
- package/dist/clients/diagnostics-publish.js +10 -3
- package/dist/clients/dispatch/auxiliary-lsp.js +34 -0
- package/dist/clients/dispatch/dispatcher.js +71 -13
- package/dist/clients/dispatch/fact-store.js +11 -0
- package/dist/clients/dispatch/facts/function-facts.js +37 -7
- package/dist/clients/dispatch/facts/import-facts.js +108 -119
- package/dist/clients/dispatch/facts/tree-sitter-facts.js +12 -12
- package/dist/clients/dispatch/facts/try-catch-facts.js +13 -0
- package/dist/clients/dispatch/indent-detect.js +35 -0
- package/dist/clients/dispatch/inline-suppressions.js +17 -8
- package/dist/clients/dispatch/integration.js +1094 -611
- package/dist/clients/dispatch/lazy.js +14 -0
- package/dist/clients/dispatch/plan.js +11 -1
- package/dist/clients/dispatch/rule-id-normalize.js +50 -0
- package/dist/clients/dispatch/rule-policy.js +135 -0
- package/dist/clients/dispatch/rules/async-noise.js +1 -0
- package/dist/clients/dispatch/rules/unsafe-boundary.js +7 -0
- package/dist/clients/dispatch/runners/ast-grep-napi.js +47 -6
- package/dist/clients/dispatch/runners/biome-check.js +5 -9
- package/dist/clients/dispatch/runners/detekt.js +3 -2
- package/dist/clients/dispatch/runners/hadolint.js +2 -1
- package/dist/clients/dispatch/runners/helm-lint.js +154 -0
- package/dist/clients/dispatch/runners/htmlhint.js +3 -2
- package/dist/clients/dispatch/runners/index.js +5 -1
- package/dist/clients/dispatch/runners/lsp.js +14 -6
- package/dist/clients/dispatch/runners/markdownlint.js +4 -9
- package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -1
- package/dist/clients/dispatch/runners/pyright.js +9 -5
- package/dist/clients/dispatch/runners/ruff.js +4 -8
- package/dist/clients/dispatch/runners/shellcheck.js +2 -3
- package/dist/clients/dispatch/runners/shfmt.js +2 -3
- package/dist/clients/dispatch/runners/spotbugs.js +2 -1
- package/dist/clients/dispatch/runners/taplo.js +2 -1
- package/dist/clients/dispatch/runners/terragrunt.js +163 -0
- package/dist/clients/dispatch/runners/tflint.js +37 -5
- package/dist/clients/dispatch/runners/tree-sitter.js +155 -166
- package/dist/clients/dispatch/runners/trivy-config.js +13 -7
- package/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +284 -34
- package/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
- package/dist/clients/disposition-publish.js +10 -3
- package/dist/clients/durable-store.js +97 -0
- package/dist/clients/event-loop-monitor.js +94 -3
- package/dist/clients/extension-log.js +139 -0
- package/dist/clients/extension-mode.js +73 -0
- package/dist/clients/feature-hints.js +53 -7
- package/dist/clients/file-kinds.js +143 -31
- package/dist/clients/file-role.js +18 -3
- package/dist/clients/file-utils.js +56 -34
- package/dist/clients/fix-worklog.js +14 -2
- package/dist/clients/format-events-publish.js +16 -3
- package/dist/clients/format-service.js +24 -8
- package/dist/clients/formatters-lazy.js +8 -0
- package/dist/clients/formatters.js +358 -61
- package/dist/clients/generated-artifacts.js +169 -15
- package/dist/clients/git-guard.js +681 -11
- package/dist/clients/git-tracked-ignore.js +4 -0
- package/dist/clients/go-client.js +2 -1
- package/dist/clients/govulncheck-client.js +8 -0
- package/dist/clients/grammar-source.js +12 -4
- package/dist/clients/gzip-stage-write.js +102 -0
- package/dist/clients/host-ports.js +31 -0
- package/dist/clients/installer/index.js +733 -213
- package/dist/clients/instance-reaper.js +142 -172
- package/dist/clients/jscpd-client.js +31 -54
- package/dist/clients/knip-client.js +78 -36
- package/dist/clients/language-policy.js +14 -2
- package/dist/clients/language-profile.js +72 -77
- package/dist/clients/latency-logger.js +28 -16
- package/dist/clients/lens-config.js +140 -166
- package/dist/clients/lens-engine.js +124 -21
- package/dist/clients/lens-events.js +27 -4
- package/dist/clients/lens-flag-registry.js +293 -0
- package/dist/clients/lens-map.js +96 -26
- package/dist/clients/live-bus-emitter.js +30 -0
- package/dist/clients/log-cleanup.js +8 -18
- package/dist/clients/lsp/cascade-tier.js +23 -3
- package/dist/clients/lsp/client.js +566 -57
- package/dist/clients/lsp/config.js +74 -10
- package/dist/clients/lsp/diagnostic-binding.js +86 -0
- package/dist/clients/lsp/edits.js +1012 -121
- package/dist/clients/lsp/index.js +1840 -803
- package/dist/clients/lsp/language.js +4 -1
- package/dist/clients/lsp/launch.js +40 -32
- package/dist/clients/lsp/path-utils.js +1 -1
- package/dist/clients/lsp/ruby-drive-dirs.js +83 -0
- package/dist/clients/lsp/server.js +279 -60
- package/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
- package/dist/clients/lsp-document-symbols.js +112 -0
- package/dist/clients/lsp-lazy.js +8 -0
- package/dist/clients/lsp-mutation.js +248 -0
- package/dist/clients/mcp/analyze.js +128 -36
- package/dist/clients/mcp/ipc.js +157 -24
- package/dist/clients/mcp/review.js +36 -3
- package/dist/clients/mcp/session.js +216 -8
- package/dist/clients/memory-sampler.js +115 -0
- package/dist/clients/metrics-client.js +2 -1
- package/dist/clients/metrics-history.js +2 -1
- package/dist/clients/module-report-lsp.js +5 -0
- package/dist/clients/module-report.js +267 -82
- package/dist/clients/ndjson-logger.js +334 -103
- package/dist/clients/opengrep-client.js +1 -1
- package/dist/clients/partial-edit-apply.js +120 -5
- package/dist/clients/path-keyed-map.js +76 -0
- package/dist/clients/path-utils.js +141 -5
- package/dist/clients/performance-report.js +327 -0
- package/dist/clients/pipeline.js +54 -13
- package/dist/clients/print-mode.js +21 -0
- package/dist/clients/project-changes.js +117 -4
- package/dist/clients/project-diagnostics/cache.js +3 -2
- package/dist/clients/project-diagnostics/extractors.js +26 -115
- package/dist/clients/project-diagnostics/fresh-fetch.js +149 -19
- package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +117 -0
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +37 -5
- package/dist/clients/project-diagnostics/scanner.js +268 -128
- package/dist/clients/project-lens-config.js +138 -34
- package/dist/clients/project-report.js +64 -25
- package/dist/clients/project-scale.js +8 -10
- package/dist/clients/project-snapshot-persist-worker.js +6 -0
- package/dist/clients/project-snapshot.js +767 -8
- package/dist/clients/project-trust.js +181 -0
- package/dist/clients/quiet-window.js +27 -5
- package/dist/clients/read-bridge.js +147 -0
- package/dist/clients/read-expansion.js +22 -12
- package/dist/clients/read-guard-logger.js +159 -1
- package/dist/clients/read-guard-tool-lines.js +150 -29
- package/dist/clients/read-guard.js +262 -3
- package/dist/clients/redact/secrets.js +449 -0
- package/dist/clients/resource-sampler.js +144 -39
- package/dist/clients/reverse-deps.js +48 -0
- package/dist/clients/review-graph/builder.js +2570 -326
- package/dist/clients/review-graph/git-identity.js +2 -1
- package/dist/clients/review-graph/import-resolvers.js +21 -10
- package/dist/clients/review-graph/persist-worker.js +10 -0
- package/dist/clients/review-graph/query.js +5 -0
- package/dist/clients/review-graph/shared-extraction-ir.js +90 -0
- package/dist/clients/review-graph/symbol-id.js +46 -0
- package/dist/clients/review-graph/tsconfig-paths.js +57 -6
- package/dist/clients/review-graph/workspace-modules.js +51 -3
- package/dist/clients/review-graph-logger.js +53 -0
- package/dist/clients/ruff-client.js +27 -44
- package/dist/clients/runtime-agent-end.js +111 -75
- package/dist/clients/runtime-context.js +36 -12
- package/dist/clients/runtime-coordinator.js +76 -8
- package/dist/clients/runtime-session.js +799 -137
- package/dist/clients/runtime-tool-call.js +117 -62
- package/dist/clients/runtime-tool-result.js +218 -23
- package/dist/clients/runtime-turn.js +284 -40
- package/dist/clients/rust-client.js +2 -1
- package/dist/clients/safe-spawn.js +873 -71
- package/dist/clients/security-scan-client.js +2 -1
- package/dist/clients/session-start-observability.js +15 -0
- package/dist/clients/session-state-store.js +5 -3
- package/dist/clients/sessionstart-logger.js +18 -0
- package/dist/clients/sg-runner.js +284 -159
- package/dist/clients/slow-fs.js +2 -1
- package/dist/clients/smells-rollup.js +224 -0
- package/dist/clients/source-filter.js +231 -43
- package/dist/clients/source-walker.js +66 -10
- package/dist/clients/startup-scan.js +15 -16
- package/dist/clients/startup-timing.js +9 -0
- package/dist/clients/test-runner-client.js +38 -8
- package/dist/clients/todo-scanner.js +30 -3
- package/dist/clients/tool-event.js +35 -0
- package/dist/clients/tool-policy.js +388 -39
- package/dist/clients/tool-render.js +155 -0
- package/dist/clients/tree-sitter-cache.js +153 -140
- package/dist/clients/tree-sitter-client.js +1722 -80
- package/dist/clients/tree-sitter-logger.js +64 -1
- package/dist/clients/tree-sitter-query-loader.js +117 -12
- package/dist/clients/tree-sitter-shared.js +48 -19
- package/dist/clients/tree-sitter-symbol-extractor.js +115 -11
- package/dist/clients/user-notify.js +51 -0
- package/dist/clients/vanished-instance-marker.js +57 -0
- package/dist/clients/warm-attach.js +33 -13
- package/dist/clients/widget-state.js +342 -49
- package/dist/clients/word-index-logger.js +44 -0
- package/dist/clients/word-index.js +612 -68
- package/dist/clients/workspace-topology.js +100 -7
- package/dist/index.js +65923 -47161
- package/dist/mcp/analyze-cli.js +185 -29
- package/dist/mcp/cli.js +110 -0
- package/dist/mcp/server.js +272 -50
- package/dist/tools/ast-grep-search.js +93 -27
- package/dist/tools/lens-diagnostics.js +247 -41
- package/dist/tools/lsp-diagnostics.js +153 -40
- package/dist/tools/lsp-navigation.js +51 -31
- package/dist/tools/module-report.js +9 -0
- package/dist/tools/symbol-search.js +21 -2
- package/docs/agent-guide.md +247 -0
- package/docs/agent-tools.md +21 -10
- package/docs/analysisall.md +192 -0
- package/docs/api-ports-inventory.md +165 -0
- package/docs/ast-grep_rules_catalog.md +5 -2
- package/docs/astplayground.md +3 -4
- package/docs/audit1.md +5 -5
- package/docs/durable-store-audit-1202.md +32 -0
- package/docs/environment-variables.md +197 -11
- package/docs/fable.md +27 -0
- package/docs/features.md +35 -5
- package/docs/globalconfig.md +101 -5
- package/docs/language-coverage.md +6 -5
- package/docs/lsp-capability-matrix.md +2 -2
- package/docs/mcp.md +54 -20
- package/docs/servercapabilities.md +7 -4
- package/docs/settings.md +192 -0
- package/docs/tools.md +18 -1
- package/docs/tree-sitter_rules_catalog.md +11 -31
- package/docs/usage.md +31 -0
- package/package.json +139 -138
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-test.yml +2 -0
- package/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
- package/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
- package/rules/ast-grep-rules/rule-tests/no-case-declarations-js-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-case-declarations-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
- package/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
- package/rules/ast-grep-rules/rule-tests/strict-equality-js-test.yml +3 -0
- package/rules/ast-grep-rules/rule-tests/strict-equality-test.yml +3 -0
- package/rules/ast-grep-rules/rule-tests/strict-inequality-js-test.yml +3 -0
- package/rules/ast-grep-rules/rule-tests/strict-inequality-test.yml +4 -0
- package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +22 -4
- package/rules/ast-grep-rules/rules/hardcoded-url.yml +22 -4
- package/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
- package/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
- package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +4 -3
- package/rules/ast-grep-rules/rules/no-case-declarations.yml +4 -3
- package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +10 -0
- package/rules/ast-grep-rules/rules/no-console-except-error.yml +10 -0
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +42 -6
- package/rules/ast-grep-rules/rules/no-javascript-url.yml +42 -6
- package/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
- package/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
- package/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
- package/rules/ast-grep-rules/rules/strict-equality-js.yml +14 -1
- package/rules/ast-grep-rules/rules/strict-equality.yml +14 -1
- package/rules/ast-grep-rules/rules/strict-inequality-js.yml +14 -1
- package/rules/ast-grep-rules/rules/strict-inequality.yml +14 -1
- package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +9 -3
- package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +6 -6
- package/rules/tree-sitter-queries/css/calc-spacing.yml +8 -6
- package/rules/tree-sitter-queries/go/go-mutex-copy.yml +21 -18
- package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +3 -3
- package/rules/tree-sitter-queries/java/infinite-loop-java.yml +3 -3
- package/rules/tree-sitter-queries/java/junit-call-super.yml +6 -4
- package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +10 -8
- package/rules/tree-sitter-queries/java/no-field-shadowing.yml +1 -1
- package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +2 -3
- package/rules/tree-sitter-queries/java/resources-closed.yml +2 -2
- package/rules/tree-sitter-queries/java/springboot-default-package.yml +6 -3
- package/rules/tree-sitter-queries/java/switch-fall-through.yml +6 -4
- package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +4 -3
- package/rules/tree-sitter-queries/java/tests-include-assertions.yml +6 -5
- package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +5 -8
- package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +3 -2
- package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +9 -5
- package/rules/tree-sitter-queries/php/this-in-static-context.yml +5 -5
- package/rules/tree-sitter-queries/python/exit-signature-check.yml +2 -9
- package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +3 -4
- package/rules/tree-sitter-queries/python/no-super-torchscript.yml +4 -8
- package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +1 -5
- package/rules/tree-sitter-queries/python/python-empty-except.yml +1 -1
- package/rules/tree-sitter-queries/python/return-in-init.yml +0 -2
- package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +0 -3
- package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +1 -12
- package/rules/tree-sitter-queries/ruby/ruby-eval.yml +1 -1
- package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +4 -4
- package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +4 -11
- package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +1 -1
- package/rules/tree-sitter-queries/ruby/ruby-string-eval.yml +2 -3
- package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +4 -4
- package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +4 -3
- package/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
- package/rules/tree-sitter-queries/typescript/console-statement.yml +11 -0
- package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +4 -9
- package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +13 -9
- package/rules/tree-sitter-queries/typescript/infinite-loop.yml +2 -2
- package/rules/tree-sitter-queries/typescript/mixed-async-styles.yml +10 -1
- package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +5 -8
- package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +7 -10
- package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +4 -2
- package/rules/typos/_typos.toml +17 -0
- package/scripts/analyze-pi-lens-logs.mjs +55 -2
- package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +5 -0
- package/dist/clients/ast-grep-parser.js +0 -86
- package/dist/clients/config-enabled-shape.js +0 -39
- package/dist/clients/lsp/interactive-install.js +0 -367
- package/dist/clients/lsp/lsp-index.js +0 -10
- package/dist/clients/lsp/server-strategies.js +0 -5
- package/dist/clients/project-metadata.js +0 -690
- package/dist/clients/source-groups.js +0 -96
- package/dist/clients/types.js +0 -11
- package/docs/env_variables.md +0 -10
- package/rules/tree-sitter-queries/abap-disabled/delete-where.yml +0 -46
- package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +0 -59
- package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +0 -58
- package/rules/tree-sitter-queries/java/mockito-initialized.yml +0 -66
- package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +0 -58
- package/rules/tree-sitter-queries/java/short-circuit-logic.yml +0 -57
- package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +0 -75
- package/rules/tree-sitter-queries/plsql-disabled/delete-update-where.yml +0 -56
- package/rules/tree-sitter-queries/plsql-disabled/end-loop-semicolon.yml +0 -51
- package/rules/tree-sitter-queries/plsql-disabled/fetch-bulk-collect-limit.yml +0 -47
- package/rules/tree-sitter-queries/plsql-disabled/forallsave-exceptions.yml +0 -51
- package/rules/tree-sitter-queries/plsql-disabled/nchar-nvarchar2-bytes.yml +0 -54
- package/rules/tree-sitter-queries/plsql-disabled/not-null-initialization.yml +0 -59
- package/rules/tree-sitter-queries/plsql-disabled/raise-application-error-codes.yml +0 -50
- package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +0 -71
|
@@ -11,25 +11,69 @@ import { createHash } from "node:crypto";
|
|
|
11
11
|
import * as nodeFs from "node:fs";
|
|
12
12
|
import fs from "node:fs/promises";
|
|
13
13
|
import path from "node:path";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { pathToFileURL, URL } from "node:url";
|
|
15
|
+
import { getProjectIgnoreMatcher, isExcludedDirName, } from "../file-utils.js";
|
|
16
16
|
import { recordLsp } from "../widget-state.js";
|
|
17
17
|
import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
|
|
18
18
|
import { detectFileRole } from "../file-role.js";
|
|
19
19
|
import { logLatency } from "../latency-logger.js";
|
|
20
|
+
import { logSessionStart } from "../sessionstart-logger.js";
|
|
21
|
+
import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
|
|
22
|
+
import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
|
|
20
23
|
import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
|
|
21
24
|
import { withDeadline } from "../deadline-utils.js";
|
|
22
25
|
import { isAtOrAboveHomeDir, normalizeMapKey, uriToPath, } from "../path-utils.js";
|
|
26
|
+
import { recordLspMutation, } from "../lsp-mutation.js";
|
|
23
27
|
import { createLSPClient } from "./client.js";
|
|
28
|
+
import { bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, } from "./diagnostic-binding.js";
|
|
24
29
|
import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
|
|
25
30
|
import { getLanguageId } from "./language.js";
|
|
26
|
-
import { LSP_SERVERS, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
|
|
31
|
+
import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
|
|
27
32
|
import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
|
|
28
33
|
import { raceToCompletion } from "./aggregation.js";
|
|
29
|
-
import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, } from "./edits.js";
|
|
34
|
+
import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, validateWorkspaceEdit, } from "./edits.js";
|
|
30
35
|
import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "./workspace-diagnostics-cache.js";
|
|
31
36
|
import { attemptTsserverSyncDiagnostics, } from "./tsserver-sync.js";
|
|
32
37
|
import { isWarmAttached, tryWarmAttachedDiagnostics, } from "../warm-attach.js";
|
|
38
|
+
function destinationUriPreservingSpelling(oldUri, oldFilePath, newFilePath) {
|
|
39
|
+
const canonical = pathToFileURL(newFilePath);
|
|
40
|
+
try {
|
|
41
|
+
const original = new URL(oldUri);
|
|
42
|
+
const authority = /^file:\/\/([^/]*)/i.exec(oldUri)?.[1] ?? "";
|
|
43
|
+
if (original.protocol !== "file:" ||
|
|
44
|
+
(original.host !== "" && original.hostname !== "localhost")) {
|
|
45
|
+
return canonical.href;
|
|
46
|
+
}
|
|
47
|
+
const canonicalDestination = () => `${original.protocol}//${authority}${canonical.pathname}`;
|
|
48
|
+
const oldParent = path.resolve(path.dirname(oldFilePath));
|
|
49
|
+
const newParent = path.resolve(path.dirname(newFilePath));
|
|
50
|
+
if (normalizeMapKey(oldParent) !== normalizeMapKey(newParent)) {
|
|
51
|
+
return canonicalDestination();
|
|
52
|
+
}
|
|
53
|
+
const slash = original.pathname.lastIndexOf("/");
|
|
54
|
+
if (slash < 0)
|
|
55
|
+
return canonicalDestination();
|
|
56
|
+
let rawName = canonical.pathname.slice(canonical.pathname.lastIndexOf("/") + 1);
|
|
57
|
+
const oldRawName = original.pathname.slice(slash + 1);
|
|
58
|
+
const oldName = decodeURIComponent(oldRawName);
|
|
59
|
+
const expectedOldName = path.basename(oldFilePath);
|
|
60
|
+
if (oldName.toLowerCase() !== expectedOldName.toLowerCase()) {
|
|
61
|
+
return canonicalDestination();
|
|
62
|
+
}
|
|
63
|
+
// Keep non-canonical percent-encoding choices from the URI that was
|
|
64
|
+
// opened (for example `%2E` instead of `.`) while using the new path's
|
|
65
|
+
// exact basename. The authority and directory spelling are preserved too.
|
|
66
|
+
for (const match of oldRawName.matchAll(/%([0-9a-f]{2})/gi)) {
|
|
67
|
+
const encoded = match[0];
|
|
68
|
+
const decoded = String.fromCharCode(Number.parseInt(match[1], 16));
|
|
69
|
+
rawName = rawName.split(decoded).join(encoded);
|
|
70
|
+
}
|
|
71
|
+
return `${original.protocol}//${authority}${original.pathname.slice(0, slash + 1)}${rawName}`;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return canonical.href;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
33
77
|
// --- Init override helpers ---
|
|
34
78
|
/**
|
|
35
79
|
* Recursively merges `override` onto `base`. Override wins on leaf conflicts
|
|
@@ -69,6 +113,41 @@ export function mergeInitializationOptions(base, override) {
|
|
|
69
113
|
const BROKEN_BASE_COOLDOWN_MS = 15_000;
|
|
70
114
|
const BROKEN_MAX_COOLDOWN_MS = 5 * 60_000; // cap at 5 minutes
|
|
71
115
|
const BROKEN_PERMANENT_AFTER = 5; // disable for session after N consecutive failures
|
|
116
|
+
// #1127: a client that dies THIS soon after a successful spawn/initialize is
|
|
117
|
+
// treated as a crash-loop symptom (opengrep's post-init "Unhandled message"
|
|
118
|
+
// exit), not a legitimate long-running server that happened to restart once.
|
|
119
|
+
// 60s comfortably covers normal serve-a-few-files-then-idle sessions while
|
|
120
|
+
// still catching "spawns fine, dies within seconds every time" servers.
|
|
121
|
+
const RUNTIME_EXIT_UPTIME_THRESHOLD_MS = 60_000;
|
|
122
|
+
// #1142: the fast path above only trips on CONSECUTIVE exits UNDER the 60s
|
|
123
|
+
// threshold, so a server that reliably dies just PAST it (~65-90s after every
|
|
124
|
+
// spawn) resets that streak on every death and churns forever — a persistent
|
|
125
|
+
// low-frequency crash loop the hard-cutoff design structurally cannot see. The
|
|
126
|
+
// windowed-rate trip COMPOSES with (does not replace) the fast path: N
|
|
127
|
+
// non-intentional deaths within a rolling M-minute window trip the breaker
|
|
128
|
+
// regardless of each death's individual lifetime.
|
|
129
|
+
// N = BROKEN_PERMANENT_AFTER (5): one uniform "5 strikes" give-up count
|
|
130
|
+
// across both streams, and high enough that the benign over-threshold
|
|
131
|
+
// deaths that reach this path (a one-off crash after a long healthy run;
|
|
132
|
+
// a single sleep/resume connection drop) — at most one per key per event
|
|
133
|
+
// — cannot approach it.
|
|
134
|
+
// M = 15 min: wide enough for a genuine slow loop (each life ~65-90s, so 5
|
|
135
|
+
// death→respawn cycles land in ~6-15 min of active churn) to accumulate
|
|
136
|
+
// 5, yet short enough that sparse benign crashes (e.g. "twice last hour,
|
|
137
|
+
// now healthy") age out of the rolling window before five ever coincide.
|
|
138
|
+
const RUNTIME_EXIT_WINDOW_MS = 15 * 60_000;
|
|
139
|
+
const RUNTIME_EXIT_WINDOW_TRIP_COUNT = BROKEN_PERMANENT_AFTER;
|
|
140
|
+
// A death whose measured lifetime (`exitedAt - spawnedAt`) exceeds this ceiling
|
|
141
|
+
// is NOT recorded toward the window: it is either a genuinely long healthy run
|
|
142
|
+
// that crashed once, or a lifetime computed ACROSS a machine-sleep / Modern-
|
|
143
|
+
// Standby suspend (the #1122/#1139 death-timestamp lesson — a suspend gap makes
|
|
144
|
+
// a benign exit look like a huge "uptime"), and neither is crash-loop churn.
|
|
145
|
+
// The slow-loop residual this fix targets dies well within the ceiling
|
|
146
|
+
// (~65-90s), so it is unaffected. This is a belt-and-suspenders guard on top of
|
|
147
|
+
// the structural defense that the window is per-KEY: a single suspend kills at
|
|
148
|
+
// most one live client per server, contributing at most one death to any one
|
|
149
|
+
// key's window, so it cannot by itself reach the trip count regardless.
|
|
150
|
+
const RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS = 10 * 60_000;
|
|
72
151
|
// #743: a server whose per-server notify write (didOpen/didChange) times out
|
|
73
152
|
// this many times in a row is a persistently backpressured server; it is demoted
|
|
74
153
|
// into the `broken` cooldown map (evicted + cooled down) so subsequent sweeps
|
|
@@ -81,6 +160,20 @@ const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(0, Number.parseInt(process.env.PI_LE
|
|
|
81
160
|
1500);
|
|
82
161
|
const TOUCH_DEBOUNCE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_TOUCH_DEBOUNCE_MS ?? "1500", 10) ||
|
|
83
162
|
1500);
|
|
163
|
+
const DEFAULT_LSP_CLIENT_CEILING = 24;
|
|
164
|
+
const DEFAULT_TS_IDLE_EVICT_MS = 20 * 60_000;
|
|
165
|
+
export function getTypeScriptIdleEvictMs() {
|
|
166
|
+
const parsed = Number.parseInt(process.env.PI_LENS_TS_IDLE_EVICT_MS ?? "", 10);
|
|
167
|
+
return Number.isSafeInteger(parsed) && parsed > 0
|
|
168
|
+
? parsed
|
|
169
|
+
: DEFAULT_TS_IDLE_EVICT_MS;
|
|
170
|
+
}
|
|
171
|
+
export function getLspClientCeiling() {
|
|
172
|
+
const parsed = Number.parseInt(process.env.PI_LENS_LSP_CLIENT_CEILING ?? "", 10);
|
|
173
|
+
return Number.isSafeInteger(parsed) && parsed > 0
|
|
174
|
+
? parsed
|
|
175
|
+
: DEFAULT_LSP_CLIENT_CEILING;
|
|
176
|
+
}
|
|
84
177
|
// #667: the sweep warm-up round trip's OWN generous, one-time budget —
|
|
85
178
|
// deliberately larger than any single per-file sweep budget (`perFileMs` in
|
|
86
179
|
// `runWorkspaceDiagnostics`, or the batch tool's per-file wait) because this
|
|
@@ -160,20 +253,6 @@ const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.
|
|
|
160
253
|
// we proceed with whatever results are ready. 0 disables early-unblock.
|
|
161
254
|
const EARLY_UNBLOCK_GRACE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_EARLY_UNBLOCK_GRACE_MS ?? "400", 10) || 400);
|
|
162
255
|
const CASCADE_DIAGNOSTICS_TTL_MS = 240_000;
|
|
163
|
-
const SESSIONSTART_LOG_DIR = getGlobalPiLensDir();
|
|
164
|
-
const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log");
|
|
165
|
-
function logSessionStart(msg) {
|
|
166
|
-
if (isTestMode()) {
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
170
|
-
void fs
|
|
171
|
-
.mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
|
|
172
|
-
.then(() => fs.appendFile(SESSIONSTART_LOG, line))
|
|
173
|
-
.catch(() => {
|
|
174
|
-
// best-effort logging
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
256
|
function mergeLspDiagnostics(diagnostics) {
|
|
178
257
|
const merged = [];
|
|
179
258
|
const seen = new Set();
|
|
@@ -293,7 +372,7 @@ const WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE = (() => {
|
|
|
293
372
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 8;
|
|
294
373
|
})();
|
|
295
374
|
// #584: opengrep has no `workspace/diagnostic` pull support (push-only,
|
|
296
|
-
// docs/servercapabilities.md) and `reopenOnResync: true` (
|
|
375
|
+
// docs/servercapabilities.md) and `reopenOnResync: true` (wait-policy/strategies.ts)
|
|
297
376
|
// means every per-file LSP touch already forces a full re-scan anyway — there's
|
|
298
377
|
// no incremental win from routing it through the sweep's per-file loop. On a
|
|
299
378
|
// full workspace sweep it instead dominates the per-file wait (its own
|
|
@@ -404,11 +483,50 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
|
|
|
404
483
|
export class LSPService {
|
|
405
484
|
state;
|
|
406
485
|
workspaceProbeLogged = new Set();
|
|
486
|
+
/** Per-service immutable root-boundary verdicts; root detectors cache hits too. */
|
|
487
|
+
projectBoundaryCache = new Map();
|
|
407
488
|
warmStartLogged = new Set();
|
|
408
489
|
optionalFailureLogged = new Set();
|
|
490
|
+
/** Server/root pairs that already emitted unavailable for the current occurrence. */
|
|
491
|
+
unavailableLogged = new Set();
|
|
409
492
|
optionalDisabled = new Set();
|
|
410
493
|
/** Consecutive failure counts for exponential backoff circuit breaker */
|
|
411
494
|
failureCounts = new Map();
|
|
495
|
+
/**
|
|
496
|
+
* #1127: consecutive EARLY post-init runtime exits — a client that closed
|
|
497
|
+
* unexpectedly (not via our own `shutdown()`) with an uptime under
|
|
498
|
+
* {@link RUNTIME_EXIT_UPTIME_THRESHOLD_MS}. Tracked separately from
|
|
499
|
+
* {@link failureCounts} because a successful spawn/initialize clears that
|
|
500
|
+
* map (correct for the spawn/init failure class it was built for — see
|
|
501
|
+
* `spawnClient`'s success path) but is exactly the event a crash-loop
|
|
502
|
+
* server keeps producing right before it dies again; reusing the same map
|
|
503
|
+
* would make every respawn erase the streak the moment it re-spawned,
|
|
504
|
+
* which is the #1127 bug. This counter shares the SAME cooldown formula,
|
|
505
|
+
* the SAME `state.broken`/`permanentlyBroken` maps, and the SAME
|
|
506
|
+
* give-up-after-N-failures threshold as the spawn/init breaker — it is a
|
|
507
|
+
* parallel counter feeding one circuit, not a second mechanism.
|
|
508
|
+
*/
|
|
509
|
+
runtimeExitCounts = new Map();
|
|
510
|
+
/**
|
|
511
|
+
* #1142: rolling window of recent non-intentional death timestamps
|
|
512
|
+
* (`exitedAt`) per "serverId:root" key (the SAME identity as
|
|
513
|
+
* {@link runtimeExitCounts} and {@link LSPState.broken} — defect shape 1) —
|
|
514
|
+
* the SECOND, independent breaker trip condition that COMPOSES with the
|
|
515
|
+
* {@link runtimeExitCounts} fast path. That counter only sees CONSECUTIVE
|
|
516
|
+
* exits under the 60s threshold; a server that dies just PAST it every spawn
|
|
517
|
+
* resets that streak forever (#1142). This window trips when
|
|
518
|
+
* {@link RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths fall within
|
|
519
|
+
* {@link RUNTIME_EXIT_WINDOW_MS}, regardless of each death's lifetime.
|
|
520
|
+
*
|
|
521
|
+
* Bounded on BOTH axes (defect shape 9): entries older than the window are
|
|
522
|
+
* pruned on every record, AND the array is hard-capped at the trip count
|
|
523
|
+
* (drop-oldest) — a rate trip needs only the count within the window, never
|
|
524
|
+
* an unbounded history of timestamps. No timer backs it (defect shape 4): it
|
|
525
|
+
* ages purely by prune-on-check, so there is nothing to `unref()` or
|
|
526
|
+
* print-mode-gate. Like {@link runtimeExitCounts} it is a per-instance field,
|
|
527
|
+
* so a fresh `LSPService` (via `resetLSPService`) starts empty.
|
|
528
|
+
*/
|
|
529
|
+
runtimeExitWindow = new Map();
|
|
412
530
|
/** Server/root keys disabled for the rest of this session after repeated failures. */
|
|
413
531
|
permanentlyBroken = new Set();
|
|
414
532
|
/**
|
|
@@ -427,7 +545,26 @@ export class LSPService {
|
|
|
427
545
|
* those falls through to a fresh check rather than serving a stale result.
|
|
428
546
|
*/
|
|
429
547
|
lastKnownContentHash = new Map();
|
|
548
|
+
/**
|
|
549
|
+
* #1095: lazily verifies a stored {@link DiagnosticBinding} against current
|
|
550
|
+
* disk bytes, memoizing the disk fingerprint per (file, mtime) so repeated
|
|
551
|
+
* binding reads across `touchFile`/`getAllDiagnostics` within a session don't
|
|
552
|
+
* re-hash unchanged files. Owned by the service so the memo is shared.
|
|
553
|
+
*/
|
|
554
|
+
diskBindingCache = createDiskBindingCache();
|
|
430
555
|
lastDiagnosticsHealth = new Map();
|
|
556
|
+
/**
|
|
557
|
+
* Touch-debounce record of "this server already has this content", keyed
|
|
558
|
+
* "normalizedFilePath:clientScope:serverId".
|
|
559
|
+
*
|
|
560
|
+
* #743/#1253: the serverId component is load-bearing. #1253 requires that a
|
|
561
|
+
* server whose notify write never landed does NOT get an entry (otherwise the
|
|
562
|
+
* next touch debounces the re-push and the silent-clean gates read its silence
|
|
563
|
+
* as a confirmed clean). #743 requires that one wedged server must not degrade
|
|
564
|
+
* its healthy siblings. A file-level key can only satisfy one of those at a
|
|
565
|
+
* time — per-server, both hold: the stalled server simply has no entry and is
|
|
566
|
+
* re-pushed, while every sibling whose write landed keeps its own debounce.
|
|
567
|
+
*/
|
|
431
568
|
recentTouches = new Map();
|
|
432
569
|
/**
|
|
433
570
|
* #743: consecutive per-server notify-write timeout count, keyed by
|
|
@@ -438,6 +575,23 @@ export class LSPService {
|
|
|
438
575
|
* successful write clears its entry.
|
|
439
576
|
*/
|
|
440
577
|
notifyWriteBackpressureStreak = new Map();
|
|
578
|
+
/** LRU clock for capacity eviction, keyed by the canonical server/root key. */
|
|
579
|
+
clientLastUsedAt = new Map();
|
|
580
|
+
/**
|
|
581
|
+
* Manager-owned use leases close the acquisition/use gap that `isBusy()`
|
|
582
|
+
* cannot see: a caller may hold a selected client before its first request has
|
|
583
|
+
* entered the client. Eviction skips any key with an outstanding lease.
|
|
584
|
+
*/
|
|
585
|
+
clientLeases = new Map();
|
|
586
|
+
/**
|
|
587
|
+
* Per-root idle eviction for TypeScript's large, rebuildable program graph.
|
|
588
|
+
* Timers are unref'd so an idle language service cannot keep a one-shot host
|
|
589
|
+
* alive, and are removed before shutdown so a concurrent request rebuilds
|
|
590
|
+
* instead of receiving the retiring client.
|
|
591
|
+
*/
|
|
592
|
+
typeScriptIdleTimers = new Map();
|
|
593
|
+
/** Serializes capacity decisions with publication into `state.inFlight`. */
|
|
594
|
+
clientSpawnGate = Promise.resolve();
|
|
441
595
|
/** True after shutdown() has been called; blocks new operations */
|
|
442
596
|
isDestroyed = false;
|
|
443
597
|
/**
|
|
@@ -460,10 +614,215 @@ export class LSPService {
|
|
|
460
614
|
demonstratedCold: new Set(),
|
|
461
615
|
};
|
|
462
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Resolve one server's client identity root. Root selection is hard-bounded
|
|
619
|
+
* by the session cwd, then config-only nested roots reuse an already-hosted
|
|
620
|
+
* same-server ancestor. Real nested projects retain independent clients.
|
|
621
|
+
*/
|
|
622
|
+
async resolveServerRoot(server, filePath) {
|
|
623
|
+
const candidate = await server.root(filePath);
|
|
624
|
+
if (!candidate)
|
|
625
|
+
return undefined;
|
|
626
|
+
const root = enforceLspRootCeiling(candidate, process.cwd(), filePath);
|
|
627
|
+
if (normalizeMapKey(root) === normalizeMapKey(process.cwd()))
|
|
628
|
+
return root;
|
|
629
|
+
const rootKey = normalizeMapKey(root);
|
|
630
|
+
const prefix = `${server.id}:`;
|
|
631
|
+
let nearestAncestor;
|
|
632
|
+
for (const key of new Set([
|
|
633
|
+
...this.state.clients.keys(),
|
|
634
|
+
...this.state.inFlight.keys(),
|
|
635
|
+
])) {
|
|
636
|
+
if (!key.startsWith(prefix))
|
|
637
|
+
continue;
|
|
638
|
+
const ancestorKey = key.slice(prefix.length);
|
|
639
|
+
const relative = path.relative(ancestorKey, rootKey);
|
|
640
|
+
if (relative === "" ||
|
|
641
|
+
relative.startsWith("..") ||
|
|
642
|
+
path.isAbsolute(relative)) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (!nearestAncestor || ancestorKey.length > nearestAncestor.length) {
|
|
646
|
+
nearestAncestor = ancestorKey;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (!nearestAncestor)
|
|
650
|
+
return root;
|
|
651
|
+
let boundary = this.projectBoundaryCache.get(rootKey);
|
|
652
|
+
if (!boundary) {
|
|
653
|
+
boundary = hasProjectBoundaryMarker(root);
|
|
654
|
+
this.projectBoundaryCache.set(rootKey, boundary);
|
|
655
|
+
}
|
|
656
|
+
if (await boundary)
|
|
657
|
+
return root;
|
|
658
|
+
return (this.state.clients.get(`${server.id}:${nearestAncestor}`)?.root ??
|
|
659
|
+
nearestAncestor);
|
|
660
|
+
}
|
|
463
661
|
/** Guard: return true if service is shutting down or shut down */
|
|
464
662
|
checkDestroyed() {
|
|
465
663
|
return this.isDestroyed;
|
|
466
664
|
}
|
|
665
|
+
async withClientSpawnGate(operation) {
|
|
666
|
+
const previous = this.clientSpawnGate;
|
|
667
|
+
let release;
|
|
668
|
+
this.clientSpawnGate = new Promise((resolve) => {
|
|
669
|
+
release = resolve;
|
|
670
|
+
});
|
|
671
|
+
await previous;
|
|
672
|
+
try {
|
|
673
|
+
return await operation();
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
release();
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async clientKeyFor(entry, filePath) {
|
|
680
|
+
const root = await this.resolveServerRoot(entry.info, filePath);
|
|
681
|
+
return root ? `${entry.info.id}:${normalizeMapKey(root)}` : undefined;
|
|
682
|
+
}
|
|
683
|
+
async acquireClientLease(entry, filePath) {
|
|
684
|
+
const key = await this.clientKeyFor(entry, filePath);
|
|
685
|
+
if (!key)
|
|
686
|
+
return undefined;
|
|
687
|
+
return this.withClientSpawnGate(async () => {
|
|
688
|
+
if (this.isDestroyed || this.state.clients.get(key) !== entry.client) {
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
this.clientLeases.set(key, (this.clientLeases.get(key) ?? 0) + 1);
|
|
692
|
+
return key;
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
async acquireClientLeases(entries, filePath) {
|
|
696
|
+
const keys = await Promise.all(entries.map((entry) => this.clientKeyFor(entry, filePath)));
|
|
697
|
+
if (keys.some((key) => key === undefined))
|
|
698
|
+
return undefined;
|
|
699
|
+
const keyed = entries.map((entry, index) => [keys[index], entry]);
|
|
700
|
+
return this.withClientSpawnGate(async () => {
|
|
701
|
+
if (this.isDestroyed ||
|
|
702
|
+
keyed.some(([key, entry]) => this.state.clients.get(key) !== entry.client)) {
|
|
703
|
+
return undefined;
|
|
704
|
+
}
|
|
705
|
+
for (const [key] of keyed) {
|
|
706
|
+
this.clientLeases.set(key, (this.clientLeases.get(key) ?? 0) + 1);
|
|
707
|
+
}
|
|
708
|
+
return keyed.map(([key]) => key);
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
releaseClientLease(key) {
|
|
712
|
+
const remaining = (this.clientLeases.get(key) ?? 1) - 1;
|
|
713
|
+
if (remaining > 0) {
|
|
714
|
+
this.clientLeases.set(key, remaining);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
this.clientLeases.delete(key);
|
|
718
|
+
if (this.state.clients.has(key)) {
|
|
719
|
+
this.clientLastUsedAt.set(key, Date.now());
|
|
720
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async withClientForFileUse(filePath, maxWaitMs, hardCapMs, use) {
|
|
724
|
+
while (!this.isDestroyed) {
|
|
725
|
+
const entry = await this.getClientForFile(filePath, maxWaitMs, hardCapMs);
|
|
726
|
+
if (!entry)
|
|
727
|
+
return undefined;
|
|
728
|
+
const leaseKey = await this.acquireClientLease(entry, filePath);
|
|
729
|
+
if (!leaseKey)
|
|
730
|
+
continue;
|
|
731
|
+
try {
|
|
732
|
+
return await use(entry);
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
this.releaseClientLease(leaseKey);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
async makeCapacityForClient(key) {
|
|
741
|
+
const occupiedKeys = new Set(this.state.inFlight.keys());
|
|
742
|
+
for (const [clientKey, client] of this.state.clients) {
|
|
743
|
+
if (client.isAlive())
|
|
744
|
+
occupiedKeys.add(clientKey);
|
|
745
|
+
}
|
|
746
|
+
const occupied = occupiedKeys.size;
|
|
747
|
+
if (occupied < getLspClientCeiling())
|
|
748
|
+
return true;
|
|
749
|
+
const idle = [...this.state.clients.entries()]
|
|
750
|
+
.filter(([candidateKey, client]) => candidateKey !== key &&
|
|
751
|
+
client.isAlive() &&
|
|
752
|
+
client.isBusy?.() !== true &&
|
|
753
|
+
(this.clientLeases.get(candidateKey) ?? 0) === 0)
|
|
754
|
+
.sort(([a], [b]) => (this.clientLastUsedAt.get(a) ?? this.state.clientSpawnedAt.get(a) ?? 0) -
|
|
755
|
+
(this.clientLastUsedAt.get(b) ?? this.state.clientSpawnedAt.get(b) ?? 0));
|
|
756
|
+
const victim = idle[0];
|
|
757
|
+
if (!victim) {
|
|
758
|
+
logSessionStart(`lsp client ceiling ${getLspClientCeiling()}: spawn declined; all clients are in use`);
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
const [victimKey, victimClient] = victim;
|
|
762
|
+
await victimClient.shutdown({ reason: "client_ceiling_lru" });
|
|
763
|
+
this.state.clients.delete(victimKey);
|
|
764
|
+
this.state.clientSpawnedAt.delete(victimKey);
|
|
765
|
+
this.state.demonstratedReady.delete(victimKey);
|
|
766
|
+
this.state.demonstratedCold.delete(victimKey);
|
|
767
|
+
this.clientLastUsedAt.delete(victimKey);
|
|
768
|
+
this.clearTypeScriptIdleTimer(victimKey);
|
|
769
|
+
logSessionStart(`lsp client ceiling ${getLspClientCeiling()}: evicted idle LRU ${victimKey}`);
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
772
|
+
clearTypeScriptIdleTimer(key) {
|
|
773
|
+
const timer = this.typeScriptIdleTimers.get(key);
|
|
774
|
+
if (timer)
|
|
775
|
+
clearTimeout(timer);
|
|
776
|
+
this.typeScriptIdleTimers.delete(key);
|
|
777
|
+
}
|
|
778
|
+
scheduleTypeScriptIdleEviction(key) {
|
|
779
|
+
if (!key.startsWith("typescript:"))
|
|
780
|
+
return;
|
|
781
|
+
// Pressure-gating these timers would require a separate reconciliation pass
|
|
782
|
+
// when the manager crosses the threshold; keep ownership simple and use the
|
|
783
|
+
// warm-LSP-friendly 20-minute default instead.
|
|
784
|
+
this.clearTypeScriptIdleTimer(key);
|
|
785
|
+
const lastUsedAt = this.clientLastUsedAt.get(key) ?? Date.now();
|
|
786
|
+
const timer = setTimeout(() => {
|
|
787
|
+
this.typeScriptIdleTimers.delete(key);
|
|
788
|
+
void this.withClientSpawnGate(async () => {
|
|
789
|
+
if (this.isDestroyed)
|
|
790
|
+
return;
|
|
791
|
+
const client = this.state.clients.get(key);
|
|
792
|
+
if (!client?.isAlive())
|
|
793
|
+
return;
|
|
794
|
+
if (client.isBusy?.() === true ||
|
|
795
|
+
(this.clientLeases.get(key) ?? 0) > 0 ||
|
|
796
|
+
(this.clientLastUsedAt.get(key) ?? 0) !== lastUsedAt) {
|
|
797
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
// Publish the cold state synchronously before awaiting teardown. A request
|
|
801
|
+
// arriving while shutdown is in progress therefore waits on the spawn gate
|
|
802
|
+
// and creates a fresh client; it can never receive this retiring one.
|
|
803
|
+
this.state.clients.delete(key);
|
|
804
|
+
this.state.clientSpawnedAt.delete(key);
|
|
805
|
+
this.state.demonstratedReady.delete(key);
|
|
806
|
+
this.state.demonstratedCold.delete(key);
|
|
807
|
+
this.clientLastUsedAt.delete(key);
|
|
808
|
+
try {
|
|
809
|
+
await client.shutdown({ reason: "typescript_idle_eviction" });
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
// The strong manager reference is already gone; shutdown is best-effort
|
|
813
|
+
// like the other eviction paths and must not reject from a timer callback.
|
|
814
|
+
}
|
|
815
|
+
logSessionStart(`lsp typescript idle eviction: released ${key}`);
|
|
816
|
+
recordDegradation({
|
|
817
|
+
kind: "ts-idle-eviction",
|
|
818
|
+
subject: key,
|
|
819
|
+
reason: "idle TypeScript client released to bound memory",
|
|
820
|
+
});
|
|
821
|
+
}).catch(() => { });
|
|
822
|
+
}, getTypeScriptIdleEvictMs());
|
|
823
|
+
timer.unref?.();
|
|
824
|
+
this.typeScriptIdleTimers.set(key, timer);
|
|
825
|
+
}
|
|
467
826
|
fingerprintContent(content) {
|
|
468
827
|
if (content.length <= 96) {
|
|
469
828
|
return `${content.length}:${content}`;
|
|
@@ -475,10 +834,15 @@ export class LSPService {
|
|
|
475
834
|
* NOT need diagnostics — those callers still need to wait for the LSP to
|
|
476
835
|
* publish, even if the notify itself is a no-op.
|
|
477
836
|
*/
|
|
478
|
-
shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics) {
|
|
837
|
+
shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics, serverIds) {
|
|
479
838
|
if (waitForDiagnostics)
|
|
480
839
|
return false;
|
|
481
|
-
|
|
840
|
+
// #743: only short-circuit the whole call when EVERY spawned server already
|
|
841
|
+
// has this content. If even one still needs the push, fall through — the
|
|
842
|
+
// write loop skips the servers that are covered and pushes only the rest.
|
|
843
|
+
if (serverIds.length === 0)
|
|
844
|
+
return false;
|
|
845
|
+
return serverIds.every((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId));
|
|
482
846
|
}
|
|
483
847
|
/**
|
|
484
848
|
* Should the didOpen/didChange notify be skipped while keeping the
|
|
@@ -492,12 +856,14 @@ export class LSPService {
|
|
|
492
856
|
* with diagnosticsMode="document" moments later. Without this check the
|
|
493
857
|
* second call's notify clears in-progress diagnostics and the LSP has to
|
|
494
858
|
* start over — observed as multi-second waits on slow TS projects.
|
|
859
|
+
*
|
|
860
|
+
* Decided PER SERVER (#743): a sibling's stalled write must not cost this
|
|
861
|
+
* server its debounce. See {@link recentTouches}.
|
|
495
862
|
*/
|
|
496
|
-
shouldSkipNotify(filePath, content, clientScope) {
|
|
863
|
+
shouldSkipNotify(filePath, content, clientScope, serverId) {
|
|
497
864
|
if (TOUCH_DEBOUNCE_MS <= 0)
|
|
498
865
|
return false;
|
|
499
|
-
const
|
|
500
|
-
const previous = this.recentTouches.get(key);
|
|
866
|
+
const previous = this.recentTouches.get(this.recentTouchKey(filePath, clientScope, serverId));
|
|
501
867
|
if (!previous)
|
|
502
868
|
return false;
|
|
503
869
|
const now = Date.now();
|
|
@@ -505,8 +871,11 @@ export class LSPService {
|
|
|
505
871
|
return false;
|
|
506
872
|
return previous.fingerprint === this.fingerprintContent(content);
|
|
507
873
|
}
|
|
508
|
-
|
|
509
|
-
|
|
874
|
+
recentTouchKey(filePath, clientScope, serverId) {
|
|
875
|
+
return `${normalizeMapKey(filePath)}:${clientScope}:${serverId}`;
|
|
876
|
+
}
|
|
877
|
+
markTouched(filePath, content, clientScope, serverId) {
|
|
878
|
+
const key = this.recentTouchKey(filePath, clientScope, serverId);
|
|
510
879
|
const now = Date.now();
|
|
511
880
|
this.recentTouches.set(key, {
|
|
512
881
|
fingerprint: this.fingerprintContent(content),
|
|
@@ -515,8 +884,10 @@ export class LSPService {
|
|
|
515
884
|
});
|
|
516
885
|
// Trim entries that are already past the debounce window — shouldSkipTouch
|
|
517
886
|
// ignores them anyway, so they serve no purpose. Only sweep when the map
|
|
518
|
-
// exceeds the threshold to avoid iterating on every call.
|
|
519
|
-
|
|
887
|
+
// exceeds the threshold to avoid iterating on every call. The threshold is
|
|
888
|
+
// per-ENTRY and entries are now per-server, so it is scaled up to keep the
|
|
889
|
+
// same effective file capacity a file-level key gave a multi-server scope.
|
|
890
|
+
if (this.recentTouches.size > 800) {
|
|
520
891
|
for (const [k, v] of this.recentTouches) {
|
|
521
892
|
if (now - v.touchedAt > TOUCH_DEBOUNCE_MS) {
|
|
522
893
|
this.recentTouches.delete(k);
|
|
@@ -536,7 +907,7 @@ export class LSPService {
|
|
|
536
907
|
* implementation that doesn't independently stamp `.root`).
|
|
537
908
|
*/
|
|
538
909
|
async demonstratedReadyKeyFor(server, filePath) {
|
|
539
|
-
const root = await
|
|
910
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
540
911
|
if (!root)
|
|
541
912
|
return undefined;
|
|
542
913
|
return `${server.id}:${normalizeMapKey(root)}`;
|
|
@@ -548,6 +919,9 @@ export class LSPService {
|
|
|
548
919
|
// the negative cache.
|
|
549
920
|
this.state.demonstratedCold.delete(key);
|
|
550
921
|
}
|
|
922
|
+
recordBreaker(key, reason) {
|
|
923
|
+
recordDegradationOnce({ kind: "lsp-breaker", subject: key, reason });
|
|
924
|
+
}
|
|
551
925
|
/**
|
|
552
926
|
* #743: record one notify-write timeout for a server and, once it has stalled
|
|
553
927
|
* {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row, demote it through
|
|
@@ -572,6 +946,8 @@ export class LSPService {
|
|
|
572
946
|
this.state.clients.delete(key);
|
|
573
947
|
this.state.clientSpawnedAt.delete(key);
|
|
574
948
|
this.state.demonstratedReady.delete(key);
|
|
949
|
+
this.clientLastUsedAt.delete(key);
|
|
950
|
+
this.clearTypeScriptIdleTimer(key);
|
|
575
951
|
logLatency({
|
|
576
952
|
type: "phase",
|
|
577
953
|
phase: "lsp_notify_backpressure_broken",
|
|
@@ -647,14 +1023,25 @@ export class LSPService {
|
|
|
647
1023
|
return spawned;
|
|
648
1024
|
}
|
|
649
1025
|
}
|
|
1026
|
+
const unavailable = (await Promise.all(servers.map(async (server) => {
|
|
1027
|
+
const root = await server.root(filePath);
|
|
1028
|
+
return {
|
|
1029
|
+
server,
|
|
1030
|
+
key: `${server.id}:${root ? normalizeMapKey(root) : "<unresolved>"}`,
|
|
1031
|
+
};
|
|
1032
|
+
}))).filter(({ key }) => !this.unavailableLogged.has(key));
|
|
1033
|
+
for (const { key } of unavailable)
|
|
1034
|
+
this.unavailableLogged.add(key);
|
|
1035
|
+
if (unavailable.length === 0)
|
|
1036
|
+
return undefined;
|
|
650
1037
|
logLatency({
|
|
651
1038
|
type: "phase",
|
|
652
1039
|
phase: "lsp_client_unavailable",
|
|
653
1040
|
filePath,
|
|
654
1041
|
durationMs: 0,
|
|
655
1042
|
metadata: {
|
|
656
|
-
candidateCount:
|
|
657
|
-
servers:
|
|
1043
|
+
candidateCount: unavailable.length,
|
|
1044
|
+
servers: unavailable.map(({ server }) => server.id),
|
|
658
1045
|
},
|
|
659
1046
|
});
|
|
660
1047
|
return undefined;
|
|
@@ -663,10 +1050,31 @@ export class LSPService {
|
|
|
663
1050
|
return withBudget();
|
|
664
1051
|
}
|
|
665
1052
|
const timeoutSentinel = Symbol("lsp-client-wait-timeout");
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
1053
|
+
// #1097: store the timer and clear it once the race settles. Without this,
|
|
1054
|
+
// when `withBudget()` wins (the common case: the client is ready well before
|
|
1055
|
+
// the budget), the losing `setTimeout` stays a REF'D pending timer for the
|
|
1056
|
+
// full remaining `effectiveMaxWaitMs`. In a long-lived interactive session
|
|
1057
|
+
// that is invisible (it fires later, resolves an orphan promise, is GC'd).
|
|
1058
|
+
// In a one-shot `pi --print --no-session` process it is fatal: the timer
|
|
1059
|
+
// keeps the event loop alive for up to `effectiveMaxWaitMs` after
|
|
1060
|
+
// `agent_settled`/`session_shutdown`, so the completed process never exits
|
|
1061
|
+
// (issue #1097 — a recurrence of the #22 symptom via a different handle, and
|
|
1062
|
+
// a member of the uncleared-race-timeout class the shared `withDeadline`
|
|
1063
|
+
// helper already guards against elsewhere).
|
|
1064
|
+
let waitTimer;
|
|
1065
|
+
let waitResult;
|
|
1066
|
+
try {
|
|
1067
|
+
waitResult = await Promise.race([
|
|
1068
|
+
withBudget(),
|
|
1069
|
+
new Promise((resolve) => {
|
|
1070
|
+
waitTimer = setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs);
|
|
1071
|
+
}),
|
|
1072
|
+
]);
|
|
1073
|
+
}
|
|
1074
|
+
finally {
|
|
1075
|
+
if (waitTimer)
|
|
1076
|
+
clearTimeout(waitTimer);
|
|
1077
|
+
}
|
|
670
1078
|
if (waitResult === timeoutSentinel) {
|
|
671
1079
|
// Snapshot known client health — scan by serverId prefix (no root needed)
|
|
672
1080
|
const knownHealth = [...this.state.clients.entries()]
|
|
@@ -705,7 +1113,7 @@ export class LSPService {
|
|
|
705
1113
|
return { clients: [], serverCountAttempted: 0 };
|
|
706
1114
|
// Count servers with a valid root as "attempted" — extension-only matches
|
|
707
1115
|
// that fail the root check are not real spawn attempts.
|
|
708
|
-
const roots = await Promise.all(servers.map((s) =>
|
|
1116
|
+
const roots = await Promise.all(servers.map((s) => this.resolveServerRoot(s, filePath)));
|
|
709
1117
|
const serverCountAttempted = roots.filter(Boolean).length;
|
|
710
1118
|
const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
|
|
711
1119
|
return {
|
|
@@ -736,7 +1144,7 @@ export class LSPService {
|
|
|
736
1144
|
return undefined;
|
|
737
1145
|
const servers = getServersForFileWithConfig(filePath);
|
|
738
1146
|
for (const server of servers) {
|
|
739
|
-
const root = await
|
|
1147
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
740
1148
|
if (!root)
|
|
741
1149
|
continue;
|
|
742
1150
|
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
@@ -758,7 +1166,7 @@ export class LSPService {
|
|
|
758
1166
|
for (const server of getServersForFileWithConfig(filePath)) {
|
|
759
1167
|
if (server.id !== serverId)
|
|
760
1168
|
continue;
|
|
761
|
-
const root = await
|
|
1169
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
762
1170
|
if (!root)
|
|
763
1171
|
continue;
|
|
764
1172
|
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
@@ -777,10 +1185,10 @@ export class LSPService {
|
|
|
777
1185
|
if (this.checkDestroyed())
|
|
778
1186
|
return undefined;
|
|
779
1187
|
}
|
|
780
|
-
const root = await
|
|
1188
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
781
1189
|
if (!root || this.checkDestroyed())
|
|
782
1190
|
return undefined;
|
|
783
|
-
const allowInstall = this.shouldAllowInstall(
|
|
1191
|
+
const allowInstall = this.shouldAllowInstall(server.id);
|
|
784
1192
|
const normalizedRoot = normalizeMapKey(root);
|
|
785
1193
|
const key = `${server.id}:${normalizedRoot}`;
|
|
786
1194
|
const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
|
|
@@ -817,6 +1225,9 @@ export class LSPService {
|
|
|
817
1225
|
const existing = this.state.clients.get(key);
|
|
818
1226
|
if (existing) {
|
|
819
1227
|
if (existing.isAlive()) {
|
|
1228
|
+
this.unavailableLogged.delete(key);
|
|
1229
|
+
this.clientLastUsedAt.set(key, Date.now());
|
|
1230
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
820
1231
|
if (!this.warmStartLogged.has(key)) {
|
|
821
1232
|
logSessionStart(`lsp warm-start ${server.id}: reused root=${root} file=${filePath}`);
|
|
822
1233
|
this.warmStartLogged.add(key);
|
|
@@ -825,6 +1236,28 @@ export class LSPService {
|
|
|
825
1236
|
}
|
|
826
1237
|
// Dead client — was previously alive, now needs respawn
|
|
827
1238
|
const spawnedAt = this.state.clientSpawnedAt.get(key);
|
|
1239
|
+
// #1127: capture BEFORE calling existing.shutdown() below — both
|
|
1240
|
+
// wasShutdownIntentional() and getExitedAt() read state that
|
|
1241
|
+
// existing.shutdown() itself mutates as a side effect of our own
|
|
1242
|
+
// cleanup (shutdownRequested flips true, and the process exit it
|
|
1243
|
+
// triggers would stamp exitedAt at CLEANUP time rather than at the
|
|
1244
|
+
// real death), so both must be read before that call.
|
|
1245
|
+
const wasIntentional = existing.wasShutdownIntentional();
|
|
1246
|
+
const exitedAt = existing.getExitedAt();
|
|
1247
|
+
// #1127: lifetime MUST be measured from the client's own recorded
|
|
1248
|
+
// death (exitedAt), not from "now" (this detection). Detection is
|
|
1249
|
+
// lazy — the next file attach — and #1127's real-world pattern is
|
|
1250
|
+
// attach-triggered respawns minutes to HOURS apart: a server that
|
|
1251
|
+
// died 5s after spawning but wasn't detected until an hour later
|
|
1252
|
+
// must still read as an early, breaker-worthy exit. Fall back to the
|
|
1253
|
+
// detection-time delta only when exitedAt is somehow unset (the
|
|
1254
|
+
// client's own exit handlers always set it before isAlive() can go
|
|
1255
|
+
// false, so this is a defensive fallback, not the expected path).
|
|
1256
|
+
const uptimeMs = exitedAt != null && spawnedAt != null
|
|
1257
|
+
? exitedAt - spawnedAt
|
|
1258
|
+
: spawnedAt != null
|
|
1259
|
+
? Date.now() - spawnedAt
|
|
1260
|
+
: null;
|
|
828
1261
|
logLatency({
|
|
829
1262
|
type: "phase",
|
|
830
1263
|
phase: "lsp_server_respawn",
|
|
@@ -833,7 +1266,8 @@ export class LSPService {
|
|
|
833
1266
|
metadata: {
|
|
834
1267
|
serverId: server.id,
|
|
835
1268
|
root,
|
|
836
|
-
uptimeMs
|
|
1269
|
+
uptimeMs,
|
|
1270
|
+
intentional: wasIntentional,
|
|
837
1271
|
},
|
|
838
1272
|
});
|
|
839
1273
|
try {
|
|
@@ -844,7 +1278,127 @@ export class LSPService {
|
|
|
844
1278
|
}
|
|
845
1279
|
this.state.clients.delete(key);
|
|
846
1280
|
this.state.clientSpawnedAt.delete(key);
|
|
1281
|
+
this.clientLastUsedAt.delete(key);
|
|
1282
|
+
this.clearTypeScriptIdleTimer(key);
|
|
847
1283
|
this.state.broken.delete(key);
|
|
1284
|
+
// #1127: count EARLY, non-intentional runtime exits toward the circuit
|
|
1285
|
+
// breaker. Spawn/initialize itself succeeded here (this client made it
|
|
1286
|
+
// into state.clients), so `failureCounts` — the spawn/init breaker —
|
|
1287
|
+
// never saw this failure and was cleared to 0 on that earlier success.
|
|
1288
|
+
// Without this, a server that spawns fine and then dies shortly after
|
|
1289
|
+
// serving (opengrep's post-init "Unhandled message" crash, #1122/#1127)
|
|
1290
|
+
// respawns forever: cooldown/permanent-disable never engage.
|
|
1291
|
+
//
|
|
1292
|
+
// Deliberate teardowns (session reset, #743 notify-backpressure
|
|
1293
|
+
// eviction, a generation handoff) call `shutdown()` themselves and set
|
|
1294
|
+
// `shutdownRequested` before the process exits — those must never count,
|
|
1295
|
+
// or a legitimate restart would wrongly march the server toward
|
|
1296
|
+
// permanent disablement.
|
|
1297
|
+
if (wasIntentional) {
|
|
1298
|
+
// Not evidence of a bad server — leave any existing runtime-exit
|
|
1299
|
+
// streak alone (a deliberate restart is not a recovery signal
|
|
1300
|
+
// either; only clear the streak once the server proves itself by
|
|
1301
|
+
// staying up past the threshold, handled in the `else` below). The
|
|
1302
|
+
// windowed-rate trip below is likewise gated behind this
|
|
1303
|
+
// `!wasIntentional` guard, so user-initiated restarts, config/
|
|
1304
|
+
// workspace reloads, session changes, #743 eviction and generation
|
|
1305
|
+
// handoffs (all shutdown()-driven) never feed it.
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
// #1142: windowed-rate trip — the SECOND, independent breaker
|
|
1309
|
+
// condition, composed with the fast path below. Record this
|
|
1310
|
+
// non-intentional death and, if RUNTIME_EXIT_WINDOW_TRIP_COUNT
|
|
1311
|
+
// deaths fell within the rolling window, give up NOW regardless of
|
|
1312
|
+
// each death's individual lifetime. This catches the slow loop
|
|
1313
|
+
// (dies just past the 60s threshold every spawn) that the
|
|
1314
|
+
// consecutive-early-exit fast path structurally cannot see.
|
|
1315
|
+
if (this.recordRuntimeExitWindow(key, exitedAt, uptimeMs)) {
|
|
1316
|
+
const rate = `${RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths within ${Math.round(RUNTIME_EXIT_WINDOW_MS / 60_000)}m`;
|
|
1317
|
+
// Set a cooldown so THIS detection also stops respawning (the
|
|
1318
|
+
// broken check below at the end of this method re-reads it),
|
|
1319
|
+
// symmetric with the fast path which cools down on every count.
|
|
1320
|
+
// `max` is defensive, not load-bearing under the actual call
|
|
1321
|
+
// order: `state.broken` for this key is `delete`d at the top of
|
|
1322
|
+
// this method (see the "detect dead client" branch above), so
|
|
1323
|
+
// this `.get(key) ?? 0` always reads 0 here — it's the fast
|
|
1324
|
+
// path below that runs AFTER this block and can raise the
|
|
1325
|
+
// cooldown for the same death. Harmless either way: the
|
|
1326
|
+
// non-optional branch latches `permanentlyBroken` (making the
|
|
1327
|
+
// cooldown moot), and the optional branch here is currently
|
|
1328
|
+
// unreachable (`OPTIONAL_LSP_SERVER_IDS` is empty).
|
|
1329
|
+
this.state.broken.set(key, Math.max(this.state.broken.get(key) ?? 0, Date.now() +
|
|
1330
|
+
(isOptionalServer
|
|
1331
|
+
? OPTIONAL_LSP_RETRY_COOLDOWN_MS
|
|
1332
|
+
: BROKEN_MAX_COOLDOWN_MS)));
|
|
1333
|
+
if (isOptionalServer) {
|
|
1334
|
+
// Optional servers never latch permanently (mirrors the fast
|
|
1335
|
+
// path): cool down + disable for a retry interval, and reset
|
|
1336
|
+
// the window so the trip must be re-earned afterward.
|
|
1337
|
+
this.optionalDisabled.add(key);
|
|
1338
|
+
this.runtimeExitWindow.delete(key);
|
|
1339
|
+
logSessionStart(`lsp respawn ${server.id}: optional server cooled down (windowed-rate trip: ${rate})`);
|
|
1340
|
+
}
|
|
1341
|
+
else {
|
|
1342
|
+
this.permanentlyBroken.add(key);
|
|
1343
|
+
this.recordBreaker(key, `windowed runtime-exit trip: ${rate}`);
|
|
1344
|
+
logSessionStart(`lsp respawn ${server.id}: permanently disabled (windowed-rate trip: ${rate}, uptimeMs=${uptimeMs})`);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
// #1127/#1139 fast path — CONSECUTIVE exits UNDER the 60s
|
|
1348
|
+
// threshold. UNCHANGED: its own counter, its own trip point. A
|
|
1349
|
+
// single death may feed BOTH accumulators (the window above AND
|
|
1350
|
+
// runtimeExitCounts here), but neither alters the other's threshold,
|
|
1351
|
+
// so the fast path's trip point is exactly what it was.
|
|
1352
|
+
if (uptimeMs != null &&
|
|
1353
|
+
uptimeMs < RUNTIME_EXIT_UPTIME_THRESHOLD_MS) {
|
|
1354
|
+
const rCount = (this.runtimeExitCounts.get(key) ?? 0) + 1;
|
|
1355
|
+
this.runtimeExitCounts.set(key, rCount);
|
|
1356
|
+
const rCooldown = Math.min(BROKEN_BASE_COOLDOWN_MS * 2 ** (rCount - 1), BROKEN_MAX_COOLDOWN_MS);
|
|
1357
|
+
this.state.broken.set(key, Date.now() + rCooldown);
|
|
1358
|
+
if (!isOptionalServer && rCount >= BROKEN_PERMANENT_AFTER) {
|
|
1359
|
+
this.permanentlyBroken.add(key);
|
|
1360
|
+
this.recordBreaker(key, `permanently disabled after ${rCount} early post-init exits`);
|
|
1361
|
+
logSessionStart(`lsp respawn ${server.id}: permanently disabled after ${rCount} early post-init exits (uptimeMs=${uptimeMs})`);
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
logSessionStart(`lsp respawn ${server.id}: early post-init exit ${rCount}/${BROKEN_PERMANENT_AFTER} (uptimeMs=${uptimeMs}), cooldown=${rCooldown}ms`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
else {
|
|
1368
|
+
// Survived past the threshold (or uptime unknown) before dying —
|
|
1369
|
+
// this was a functioning server, not a HOT crash loop. Reset the
|
|
1370
|
+
// consecutive-early streak so an occasional post-longrun crash
|
|
1371
|
+
// doesn't creep it toward permanent disablement. The windowed
|
|
1372
|
+
// counter above is deliberately NOT reset here: a ~65-90s death
|
|
1373
|
+
// lands in this branch yet IS the churn #1142 targets — the
|
|
1374
|
+
// window ages those out by time instead.
|
|
1375
|
+
this.runtimeExitCounts.delete(key);
|
|
1376
|
+
// Outer-map hygiene (#1183): runtimeExitWindow itself is only
|
|
1377
|
+
// ever `delete`d on the optional-trip branch above (currently
|
|
1378
|
+
// unreachable — OPTIONAL_LSP_SERVER_IDS is empty), so a key
|
|
1379
|
+
// that crashed once then recovered would otherwise retain a
|
|
1380
|
+
// stale timestamp array forever, even once every entry in it
|
|
1381
|
+
// has aged out of the window. Drop it here once nothing in it
|
|
1382
|
+
// is still in-window — purely map hygiene: a later death
|
|
1383
|
+
// rebuilds the array from scratch via recordRuntimeExitWindow,
|
|
1384
|
+
// so this cannot change trip behavior. Anchor "now" on this
|
|
1385
|
+
// death's own `exitedAt` (falling back to real time only if
|
|
1386
|
+
// unset), NOT `Date.now()` directly — this death's timestamp
|
|
1387
|
+
// is what `recordRuntimeExitWindow` itself anchors pruning on
|
|
1388
|
+
// (see its `windowRef` comment), so a death that WAS just
|
|
1389
|
+
// recorded is guaranteed to still be in-window here and this
|
|
1390
|
+
// can only ever fire for the "declined to record" cases
|
|
1391
|
+
// (over the sleep-gap ceiling, or a missing `exitedAt`) where
|
|
1392
|
+
// nothing new was added and the array is genuinely stale.
|
|
1393
|
+
const windowDeaths = this.runtimeExitWindow.get(key);
|
|
1394
|
+
if (windowDeaths) {
|
|
1395
|
+
const windowNow = exitedAt ?? Date.now();
|
|
1396
|
+
if (!windowDeaths.some((t) => t >= windowNow - RUNTIME_EXIT_WINDOW_MS)) {
|
|
1397
|
+
this.runtimeExitWindow.delete(key);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
848
1402
|
}
|
|
849
1403
|
const brokenUntil = this.state.broken.get(key);
|
|
850
1404
|
if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
|
|
@@ -865,31 +1419,53 @@ export class LSPService {
|
|
|
865
1419
|
if (isOptionalServer)
|
|
866
1420
|
this.optionalDisabled.delete(key);
|
|
867
1421
|
}
|
|
868
|
-
|
|
869
|
-
if (
|
|
870
|
-
|
|
1422
|
+
let spawnPromise = this.state.inFlight.get(key);
|
|
1423
|
+
if (!spawnPromise) {
|
|
1424
|
+
const started = await this.withClientSpawnGate(async () => {
|
|
1425
|
+
const raced = this.state.inFlight.get(key);
|
|
1426
|
+
if (raced)
|
|
1427
|
+
return { promise: raced };
|
|
1428
|
+
// `server.root()` and dead-client cleanup above are async. A reset during
|
|
1429
|
+
// either gap must not let this retired generation start a late spawn.
|
|
1430
|
+
if (this.checkDestroyed())
|
|
1431
|
+
return undefined;
|
|
1432
|
+
if (!(await this.makeCapacityForClient(key)))
|
|
1433
|
+
return undefined;
|
|
1434
|
+
const promise = this.spawnClient(server, root, key, filePath, allowInstall);
|
|
1435
|
+
this.state.inFlight.set(key, promise);
|
|
1436
|
+
return { promise };
|
|
1437
|
+
});
|
|
1438
|
+
if (!started)
|
|
1439
|
+
return undefined;
|
|
1440
|
+
spawnPromise = started.promise;
|
|
871
1441
|
}
|
|
872
|
-
// `server.root()` and a dead client's shutdown above are both async. A
|
|
873
|
-
// reset during either gap may have completed without seeing this request in
|
|
874
|
-
// state.inFlight; never let that retired generation start a late spawn.
|
|
875
|
-
if (this.checkDestroyed())
|
|
876
|
-
return undefined;
|
|
877
|
-
const spawnPromise = this.spawnClient(server, root, key, filePath, allowInstall);
|
|
878
|
-
this.state.inFlight.set(key, spawnPromise);
|
|
879
1442
|
try {
|
|
880
1443
|
return await spawnPromise;
|
|
881
1444
|
}
|
|
882
1445
|
finally {
|
|
883
|
-
this.state.inFlight.
|
|
1446
|
+
if (this.state.inFlight.get(key) === spawnPromise) {
|
|
1447
|
+
this.state.inFlight.delete(key);
|
|
1448
|
+
}
|
|
884
1449
|
}
|
|
885
1450
|
}
|
|
886
|
-
shouldAllowInstall(
|
|
1451
|
+
shouldAllowInstall(serverId) {
|
|
1452
|
+
if (!assertInstallAllowed(`lsp install: ${serverId}`))
|
|
1453
|
+
return false;
|
|
887
1454
|
return process.env.PI_LENS_DISABLE_LSP_INSTALL !== "1";
|
|
888
1455
|
}
|
|
889
1456
|
/**
|
|
890
1457
|
* Internal: spawn a client for a server/root combination
|
|
891
1458
|
*/
|
|
892
1459
|
async spawnClient(server, root, key, filePath, allowInstall) {
|
|
1460
|
+
// #1334 S5: honor the host project-trust decision before executing any
|
|
1461
|
+
// project-resolved binary. Only an explicit host "not trusted" blocks —
|
|
1462
|
+
// a host with no trust surface (`"unknown"`) spawns exactly as before.
|
|
1463
|
+
// Deliberately NOT marked broken: trust is a policy outcome, not a server
|
|
1464
|
+
// failure, and the user may grant trust later in the same session.
|
|
1465
|
+
if (!isLspSpawnAllowedByTrust()) {
|
|
1466
|
+
logSessionStart(`lsp spawn ${server.id}: refused — ${projectTrustDenialReason()}`);
|
|
1467
|
+
return undefined;
|
|
1468
|
+
}
|
|
893
1469
|
const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
|
|
894
1470
|
const startedAt = Date.now();
|
|
895
1471
|
logSessionStart(`lsp spawn ${server.id}: start root=${root} install=${allowInstall ? "enabled" : "disabled"} file=${filePath}`);
|
|
@@ -928,6 +1504,7 @@ export class LSPService {
|
|
|
928
1504
|
this.state.broken.set(key, Date.now() + uCooldown);
|
|
929
1505
|
if (uCount >= BROKEN_PERMANENT_AFTER) {
|
|
930
1506
|
this.permanentlyBroken.add(key);
|
|
1507
|
+
this.recordBreaker(key, `permanently disabled after ${uCount} unavailable spawns`);
|
|
931
1508
|
logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${uCount} failures`);
|
|
932
1509
|
}
|
|
933
1510
|
return undefined;
|
|
@@ -957,7 +1534,10 @@ export class LSPService {
|
|
|
957
1534
|
diagnosticProviderKind: "unavailable",
|
|
958
1535
|
};
|
|
959
1536
|
this.state.clients.set(key, client);
|
|
1537
|
+
this.unavailableLogged.delete(key);
|
|
960
1538
|
this.state.clientSpawnedAt.set(key, Date.now());
|
|
1539
|
+
this.clientLastUsedAt.set(key, Date.now());
|
|
1540
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
961
1541
|
this.failureCounts.delete(key);
|
|
962
1542
|
if (isOptionalServer) {
|
|
963
1543
|
this.optionalDisabled.delete(key);
|
|
@@ -987,6 +1567,7 @@ export class LSPService {
|
|
|
987
1567
|
this.state.broken.set(key, Date.now() + eCooldown);
|
|
988
1568
|
if (!isOptionalServer && eCount >= BROKEN_PERMANENT_AFTER) {
|
|
989
1569
|
this.permanentlyBroken.add(key);
|
|
1570
|
+
this.recordBreaker(key, `permanently disabled after ${eCount} spawn/initialize failures`);
|
|
990
1571
|
logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${eCount} failures`);
|
|
991
1572
|
}
|
|
992
1573
|
if (isOptionalServer) {
|
|
@@ -1001,11 +1582,10 @@ export class LSPService {
|
|
|
1001
1582
|
async openFile(filePath, content, options) {
|
|
1002
1583
|
if (this.checkDestroyed())
|
|
1003
1584
|
return;
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
|
|
1585
|
+
await this.withClientForFileUse(filePath, undefined, options?.spawnBudgetMs, async (spawned) => {
|
|
1586
|
+
const languageId = getLanguageId(filePath) ?? "plaintext";
|
|
1587
|
+
await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
|
|
1588
|
+
});
|
|
1009
1589
|
}
|
|
1010
1590
|
/**
|
|
1011
1591
|
* Update file content (sends textDocument/didChange)
|
|
@@ -1013,10 +1593,7 @@ export class LSPService {
|
|
|
1013
1593
|
async updateFile(filePath, content) {
|
|
1014
1594
|
if (this.checkDestroyed())
|
|
1015
1595
|
return;
|
|
1016
|
-
|
|
1017
|
-
if (!spawned)
|
|
1018
|
-
return;
|
|
1019
|
-
await spawned.client.notify.change(filePath, content);
|
|
1596
|
+
await this.withClientForFileUse(filePath, undefined, undefined, (spawned) => spawned.client.notify.change(filePath, content));
|
|
1020
1597
|
}
|
|
1021
1598
|
/**
|
|
1022
1599
|
* Touch a file like OpenCode's LSP flow: ensure document is open/synced,
|
|
@@ -1078,533 +1655,733 @@ export class LSPService {
|
|
|
1078
1655
|
});
|
|
1079
1656
|
return;
|
|
1080
1657
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
durationMs: Date.now() - startedAt,
|
|
1087
|
-
metadata: {
|
|
1088
|
-
serverCountReady: spawned.length,
|
|
1089
|
-
clientScope,
|
|
1090
|
-
diagnosticsMode,
|
|
1091
|
-
source,
|
|
1092
|
-
failureKind: "success",
|
|
1093
|
-
skipped: true,
|
|
1094
|
-
reason: "debounced_unchanged_content",
|
|
1095
|
-
},
|
|
1096
|
-
});
|
|
1097
|
-
return [];
|
|
1658
|
+
const leaseKeys = await this.acquireClientLeases(spawned, filePath);
|
|
1659
|
+
if (!leaseKeys) {
|
|
1660
|
+
// An idle eviction won between selection and lease admission. Resolve the
|
|
1661
|
+
// now-current client set and replay; no notification targets the retiree.
|
|
1662
|
+
return this.touchFile(filePath, content, options);
|
|
1098
1663
|
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
// debounce window, skip the notify — pushing again clears the LSP's
|
|
1103
|
-
// diagnostic cache (via notify.open) and forces it to restart work it
|
|
1104
|
-
// already did. This is what makes the post-write touch + dispatch-lsp-
|
|
1105
|
-
// runner touch sequence expensive on slow TS projects.
|
|
1106
|
-
const notifySkipped = this.shouldSkipNotify(filePath, content, clientScope);
|
|
1107
|
-
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
|
|
1108
|
-
// #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
|
|
1109
|
-
// write gets its OWN notifyWriteBudgetMs budget rather than one shared
|
|
1110
|
-
// deadline over a single Promise.all — otherwise one backpressured server
|
|
1111
|
-
// (stalled stdin) times out the write for the ENTIRE file, flipping every
|
|
1112
|
-
// co-touched healthy server to inconclusive and zeroing its diagnostics.
|
|
1113
|
-
// Bounded so a backpressured write can't hang the caller; on timeout we
|
|
1114
|
-
// proceed — the diagnostics wait below is separately bounded and simply
|
|
1115
|
-
// returns no fresh diagnostics for the server(s) that stalled.
|
|
1116
|
-
//
|
|
1117
|
-
// Holds the serverId of every server whose write did NOT land in time.
|
|
1118
|
-
// The file-level `notifyWriteTimedOut` (logged below) means "at least one
|
|
1119
|
-
// server timed out"; this list carries the per-server detail the
|
|
1120
|
-
// demonstratedReady gate reads so a healthy sibling stays eligible.
|
|
1121
|
-
const notifyWriteTimedOutServerIds = [];
|
|
1122
|
-
if (!notifySkipped) {
|
|
1123
|
-
const budget = notifyWriteBudgetMs();
|
|
1124
|
-
await Promise.all(spawned.map(async (entry) => {
|
|
1125
|
-
// Same identity as the broken/demonstratedReady maps.
|
|
1126
|
-
const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1127
|
-
let wrote;
|
|
1128
|
-
let rejected = false;
|
|
1129
|
-
try {
|
|
1130
|
-
wrote = await withDeadline(entry.client.notify
|
|
1131
|
-
.open(filePath, content, languageId, undefined, silent)
|
|
1132
|
-
.then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
|
|
1133
|
-
}
|
|
1134
|
-
catch {
|
|
1135
|
-
// The write itself rejected (not backpressure): the content did
|
|
1136
|
-
// not land, so this server is inconclusive for the touch, but a
|
|
1137
|
-
// rejection is not a stdin-backpressure signal and must not count
|
|
1138
|
-
// toward the backpressure demotion streak.
|
|
1139
|
-
rejected = true;
|
|
1140
|
-
}
|
|
1141
|
-
if (wrote === true) {
|
|
1142
|
-
// A clean write clears any accrued backpressure streak (#743).
|
|
1143
|
-
if (clientKey)
|
|
1144
|
-
this.notifyWriteBackpressureStreak.delete(clientKey);
|
|
1145
|
-
}
|
|
1146
|
-
else {
|
|
1147
|
-
notifyWriteTimedOutServerIds.push(entry.info.id);
|
|
1148
|
-
if (!rejected) {
|
|
1149
|
-
this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
return true;
|
|
1153
|
-
}));
|
|
1154
|
-
if (notifyWriteTimedOutServerIds.length > 0) {
|
|
1664
|
+
try {
|
|
1665
|
+
const spawnedServerIds = spawned.map((entry) => entry.info.id);
|
|
1666
|
+
if (this.shouldSkipTouch(filePath, content, clientScope, diagnosticsMode !== "none", spawnedServerIds)) {
|
|
1155
1667
|
logLatency({
|
|
1156
1668
|
type: "phase",
|
|
1157
|
-
phase: "
|
|
1669
|
+
phase: "lsp_touch_file",
|
|
1158
1670
|
filePath: normalizedPath,
|
|
1159
1671
|
durationMs: Date.now() - startedAt,
|
|
1160
1672
|
metadata: {
|
|
1161
|
-
|
|
1673
|
+
serverCountReady: spawned.length,
|
|
1162
1674
|
clientScope,
|
|
1163
|
-
|
|
1164
|
-
|
|
1675
|
+
diagnosticsMode,
|
|
1676
|
+
source,
|
|
1677
|
+
failureKind: "success",
|
|
1678
|
+
skipped: true,
|
|
1679
|
+
reason: "debounced_unchanged_content",
|
|
1165
1680
|
},
|
|
1166
1681
|
});
|
|
1682
|
+
// #1179: a debounced skip collected nothing — resolve the wrapper's
|
|
1683
|
+
// `.diags` as empty with neither flag (exactly the pre-wrapper `[]`).
|
|
1684
|
+
return { diags: [] };
|
|
1167
1685
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
// paths never set this). Logged in lsp_touch_file metadata.
|
|
1176
|
-
let auxCutOffServerIds;
|
|
1177
|
-
// #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
|
|
1178
|
-
// full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
|
|
1179
|
-
// holds the sync commands' answer when the racing confirm won the wait
|
|
1180
|
-
// (undefined = the race didn't produce an answer; the end-of-wait
|
|
1181
|
-
// fallback below may still fill it in on a timed-out empty result).
|
|
1182
|
-
let tsserverSyncEligible = false;
|
|
1183
|
-
let tsserverSyncConfirmed;
|
|
1184
|
-
if (diagnosticsMode !== "none") {
|
|
1185
|
-
// Resolution: env wins so users can tune the cap without rebuilding.
|
|
1186
|
-
// Otherwise, on the single-server hot path (primary scope), use that
|
|
1187
|
-
// server's own strategy budget (server-strategies.ts) so a fast server
|
|
1188
|
-
// (TypeScript ~1s) isn't held to a flat multi-second wait while a slow
|
|
1189
|
-
// one (rust-analyzer 3s) gets the time it needs — bounded by any caller
|
|
1190
|
-
// ceiling that exists to protect the per-edit pipeline budget (#203).
|
|
1191
|
-
// #573: clientScope "all" (lsp_diagnostics, lens_diagnostics_full) now
|
|
1192
|
-
// gets the same per-server treatment as "with-auxiliary" — each spawned
|
|
1193
|
-
// server (primary + any auxiliaries) is bounded by ITS OWN strategy
|
|
1194
|
-
// budget instead of one flat number shared by every server. This was
|
|
1195
|
-
// never a deliberate "all means wait for the group ceiling" semantic:
|
|
1196
|
-
// #203 introduced perServerTimeout only for the single-server primary
|
|
1197
|
-
// path and left "full"/"all" on the pre-existing flat resolution
|
|
1198
|
-
// ("full/cascade path unchanged"); #242 later added "with-auxiliary"
|
|
1199
|
-
// without revisiting "all". The one property "all" genuinely needs —
|
|
1200
|
-
// the touch's overall detection deadline is the SLOWEST spawned
|
|
1201
|
-
// server's budget, not the fastest — is unaffected: `timeoutMs` below
|
|
1202
|
-
// is always `Math.max(...spawned.map(timeoutFor))` regardless of which
|
|
1203
|
-
// timeoutFor is selected, so a slow auxiliary still gets to run to its
|
|
1204
|
-
// own budget before the touch is logged as timed out. What changes is
|
|
1205
|
-
// only that a fast server's *individual* `waitForDiagnostics` call
|
|
1206
|
-
// (further below) now resolves/times out against its own budget
|
|
1207
|
-
// instead of blocking to the flat multi-server number.
|
|
1208
|
-
const envWait = readEnvDiagnosticsWaitMs();
|
|
1209
|
-
const callerCap = options.maxDiagnosticsWaitMs ?? options.maxClientWaitMs;
|
|
1210
|
-
const modeFloor = diagnosticsMode === "full" ? 3000 : 1200;
|
|
1211
|
-
// #645: resolve each spawned server's "is this the first same-sweep
|
|
1212
|
-
// touch for it" verdict EXACTLY ONCE up front, before `perServerTimeout`
|
|
1213
|
-
// is defined. `SweepIndexGate.consumeFirstTouch` is side-effecting
|
|
1214
|
-
// (it marks the server seen), and `perServerTimeout` below is invoked
|
|
1215
|
-
// twice per server in this call (once to compute the overall
|
|
1216
|
-
// `timeoutMs` deadline, again inside the wait `Promise.all`) — calling
|
|
1217
|
-
// the gate directly from inside `perServerTimeout` would consume the
|
|
1218
|
-
// "first touch" slot on the first of those two calls and read as
|
|
1219
|
-
// already-warm on the second, silently shortchanging the very touch
|
|
1220
|
-
// that was supposed to get the full budget.
|
|
1221
|
-
const sweepFirstTouch = new Map();
|
|
1222
|
-
if (options.sweepIndexGate) {
|
|
1223
|
-
for (const entry of spawned) {
|
|
1224
|
-
const strategy = getStrategy(entry.client.serverId);
|
|
1225
|
-
if (strategy.workspaceIndexing) {
|
|
1226
|
-
sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
// #832: workspace-indexing servers that are classified as silent on
|
|
1231
|
-
// clean do not benefit from the generic cold-indexing floor. Their
|
|
1232
|
-
// configured strategy already gives the first sweep touch a bounded
|
|
1233
|
-
// workspace-index budget (marksman: 1500ms), while the capability
|
|
1234
|
-
// classification proves that a clean push has no affirmative signal to
|
|
1235
|
-
// wait for. Keep this restricted to the workspace-indexing strategy:
|
|
1236
|
-
// TypeScript is also a silent-on-clean push server, but its cold project
|
|
1237
|
-
// load still needs the longer 20s floor.
|
|
1686
|
+
const languageId = getLanguageId(filePath) ?? "plaintext";
|
|
1687
|
+
const silent = options.silent ?? false;
|
|
1688
|
+
// When the same content was already pushed to the LSP within the touch
|
|
1689
|
+
// debounce window, skip the notify — pushing again clears the LSP's
|
|
1690
|
+
// diagnostic cache (via notify.open) and forces it to restart work it
|
|
1691
|
+
// already did. This is what makes the post-write touch + dispatch-lsp-
|
|
1692
|
+
// runner touch sequence expensive on slow TS projects.
|
|
1238
1693
|
//
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1694
|
+
// #743: resolved PER SERVER. A server whose sibling's write stalled last
|
|
1695
|
+
// touch still holds its own debounce entry, so it is skipped here while the
|
|
1696
|
+
// stalled server (which has no entry) gets re-pushed. `notifySkipped` stays
|
|
1697
|
+
// as the file-level "every server was skipped" summary for the logs and the
|
|
1698
|
+
// no-new-version baseline below.
|
|
1699
|
+
const notifySkippedServerIds = new Set(spawnedServerIds.filter((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId)));
|
|
1700
|
+
const notifySkipped = spawned.length > 0 && notifySkippedServerIds.size === spawned.length;
|
|
1701
|
+
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
|
|
1702
|
+
// #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
|
|
1703
|
+
// write gets its OWN notifyWriteBudgetMs budget rather than one shared
|
|
1704
|
+
// deadline over a single Promise.all — otherwise one backpressured server
|
|
1705
|
+
// (stalled stdin) times out the write for the ENTIRE file, flipping every
|
|
1706
|
+
// co-touched healthy server to inconclusive and zeroing its diagnostics.
|
|
1707
|
+
// Bounded so a backpressured write can't hang the caller; on timeout we
|
|
1708
|
+
// proceed — the diagnostics wait below is separately bounded and simply
|
|
1709
|
+
// returns no fresh diagnostics for the server(s) that stalled.
|
|
1710
|
+
//
|
|
1711
|
+
// Holds the serverId of every server whose write did NOT land in time.
|
|
1712
|
+
// The file-level `notifyWriteTimedOut` (logged below) means "at least one
|
|
1713
|
+
// server timed out"; this list carries the per-server detail the
|
|
1714
|
+
// demonstratedReady gate reads so a healthy sibling stays eligible.
|
|
1715
|
+
const notifyWriteTimedOutServerIds = [];
|
|
1716
|
+
if (!notifySkipped) {
|
|
1717
|
+
const budget = notifyWriteBudgetMs();
|
|
1718
|
+
await Promise.all(spawned.map(async (entry) => {
|
|
1719
|
+
// #743: this server already has this content from a recent touch
|
|
1720
|
+
// that landed. Pushing again would clear its diagnostic cache for
|
|
1721
|
+
// nothing — leave its debounce entry (and its original timestamp)
|
|
1722
|
+
// alone so the window still expires naturally.
|
|
1723
|
+
if (notifySkippedServerIds.has(entry.info.id))
|
|
1724
|
+
return;
|
|
1725
|
+
// Same identity as the broken/demonstratedReady maps.
|
|
1726
|
+
const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1727
|
+
let wrote;
|
|
1728
|
+
let rejected = false;
|
|
1248
1729
|
try {
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
operationSupport: entry.client.getOperationSupport(),
|
|
1253
|
-
workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
|
|
1254
|
-
advertisedCommands: entry.client.getAdvertisedCommands(),
|
|
1255
|
-
rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
|
|
1256
|
-
launchVariant: entry.client.getLaunchVariant?.(),
|
|
1257
|
-
};
|
|
1258
|
-
if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
|
|
1259
|
-
"tier3-silent") {
|
|
1260
|
-
silentCleanWarmupServers.add(entry.client.serverId);
|
|
1261
|
-
}
|
|
1730
|
+
wrote = await withDeadline(entry.client.notify
|
|
1731
|
+
.open(filePath, content, languageId, undefined, silent)
|
|
1732
|
+
.then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
|
|
1262
1733
|
}
|
|
1263
1734
|
catch {
|
|
1264
|
-
//
|
|
1735
|
+
// The write itself rejected (not backpressure): the content did
|
|
1736
|
+
// not land, so this server is inconclusive for the touch, but a
|
|
1737
|
+
// rejection is not a stdin-backpressure signal and must not count
|
|
1738
|
+
// toward the backpressure demotion streak.
|
|
1739
|
+
rejected = true;
|
|
1265
1740
|
}
|
|
1741
|
+
if (wrote === true) {
|
|
1742
|
+
// A clean write clears any accrued backpressure streak (#743).
|
|
1743
|
+
if (clientKey)
|
|
1744
|
+
this.notifyWriteBackpressureStreak.delete(clientKey);
|
|
1745
|
+
// #1253: record the debounce entry for THIS server only, and only
|
|
1746
|
+
// because its own write landed. A server whose write timed out or
|
|
1747
|
+
// rejected falls through without an entry, so the next touch
|
|
1748
|
+
// re-pushes it instead of laundering the failure into a later
|
|
1749
|
+
// touch that looks fully delivered (which the silent-clean gates
|
|
1750
|
+
// would then read as a confirmed clean).
|
|
1751
|
+
this.markTouched(filePath, content, clientScope, entry.info.id);
|
|
1752
|
+
}
|
|
1753
|
+
else {
|
|
1754
|
+
notifyWriteTimedOutServerIds.push(entry.info.id);
|
|
1755
|
+
if (!rejected) {
|
|
1756
|
+
this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}));
|
|
1760
|
+
if (notifyWriteTimedOutServerIds.length > 0) {
|
|
1761
|
+
logLatency({
|
|
1762
|
+
type: "phase",
|
|
1763
|
+
phase: "lsp_notify_timeout",
|
|
1764
|
+
filePath: normalizedPath,
|
|
1765
|
+
durationMs: Date.now() - startedAt,
|
|
1766
|
+
metadata: {
|
|
1767
|
+
source,
|
|
1768
|
+
clientScope,
|
|
1769
|
+
serverCount: spawned.length,
|
|
1770
|
+
timedOutServerIds: notifyWriteTimedOutServerIds,
|
|
1771
|
+
},
|
|
1772
|
+
});
|
|
1266
1773
|
}
|
|
1267
1774
|
}
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
//
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1775
|
+
// File-level flag: at least one server's write timed out (kept for the
|
|
1776
|
+
// conservative touch-wide `inconclusive` merge semantics — see below).
|
|
1777
|
+
const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
|
|
1778
|
+
let diagnosticsTimedOut = false;
|
|
1779
|
+
// R8 (#714): server ids of aux-role servers whose push wait was cut off by
|
|
1780
|
+
// the aux grace window. Undefined when no aux was cut off (primary-only
|
|
1781
|
+
// paths never set this). Logged in lsp_touch_file metadata.
|
|
1782
|
+
let auxCutOffServerIds;
|
|
1783
|
+
// #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
|
|
1784
|
+
// full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
|
|
1785
|
+
// holds the sync commands' answer when the racing confirm won the wait
|
|
1786
|
+
// (undefined = the race didn't produce an answer; the end-of-wait
|
|
1787
|
+
// fallback below may still fill it in on a timed-out empty result).
|
|
1788
|
+
let tsserverSyncEligible = false;
|
|
1789
|
+
let tsserverSyncConfirmed;
|
|
1790
|
+
if (diagnosticsMode !== "none") {
|
|
1791
|
+
// Resolution: env wins so users can tune the cap without rebuilding.
|
|
1792
|
+
// Otherwise, on the single-server hot path (primary scope), use that
|
|
1793
|
+
// server's own strategy budget (wait-policy/strategies.ts) so a fast server
|
|
1794
|
+
// (TypeScript ~1s) isn't held to a flat multi-second wait while a slow
|
|
1795
|
+
// one (rust-analyzer 3s) gets the time it needs — bounded by any caller
|
|
1796
|
+
// ceiling that exists to protect the per-edit pipeline budget (#203).
|
|
1797
|
+
// #573: clientScope "all" (lsp_diagnostics, lens_diagnostics_full) now
|
|
1798
|
+
// gets the same per-server treatment as "with-auxiliary" — each spawned
|
|
1799
|
+
// server (primary + any auxiliaries) is bounded by ITS OWN strategy
|
|
1800
|
+
// budget instead of one flat number shared by every server. This was
|
|
1801
|
+
// never a deliberate "all means wait for the group ceiling" semantic:
|
|
1802
|
+
// #203 introduced perServerTimeout only for the single-server primary
|
|
1803
|
+
// path and left "full"/"all" on the pre-existing flat resolution
|
|
1804
|
+
// ("full/cascade path unchanged"); #242 later added "with-auxiliary"
|
|
1805
|
+
// without revisiting "all". The one property "all" genuinely needs —
|
|
1806
|
+
// the touch's overall detection deadline is the SLOWEST spawned
|
|
1807
|
+
// server's budget, not the fastest — is unaffected: `timeoutMs` below
|
|
1808
|
+
// is always `Math.max(...spawned.map(timeoutFor))` regardless of which
|
|
1809
|
+
// timeoutFor is selected, so a slow auxiliary still gets to run to its
|
|
1810
|
+
// own budget before the touch is logged as timed out. What changes is
|
|
1811
|
+
// only that a fast server's *individual* `waitForDiagnostics` call
|
|
1812
|
+
// (further below) now resolves/times out against its own budget
|
|
1813
|
+
// instead of blocking to the flat multi-server number.
|
|
1814
|
+
const envWait = readEnvDiagnosticsWaitMs();
|
|
1815
|
+
const callerCap = options.maxDiagnosticsWaitMs ?? options.maxClientWaitMs;
|
|
1816
|
+
const modeFloor = diagnosticsMode === "full" ? 3000 : 1200;
|
|
1817
|
+
// #645: resolve each spawned server's "is this the first same-sweep
|
|
1818
|
+
// touch for it" verdict EXACTLY ONCE up front, before `perServerTimeout`
|
|
1819
|
+
// is defined. `SweepIndexGate.consumeFirstTouch` is side-effecting
|
|
1820
|
+
// (it marks the server seen), and `perServerTimeout` below is invoked
|
|
1821
|
+
// twice per server in this call (once to compute the overall
|
|
1822
|
+
// `timeoutMs` deadline, again inside the wait `Promise.all`) — calling
|
|
1823
|
+
// the gate directly from inside `perServerTimeout` would consume the
|
|
1824
|
+
// "first touch" slot on the first of those two calls and read as
|
|
1825
|
+
// already-warm on the second, silently shortchanging the very touch
|
|
1826
|
+
// that was supposed to get the full budget.
|
|
1827
|
+
const sweepFirstTouch = new Map();
|
|
1828
|
+
if (options.sweepIndexGate) {
|
|
1829
|
+
for (const entry of spawned) {
|
|
1830
|
+
const strategy = getStrategy(entry.client.serverId);
|
|
1831
|
+
if (strategy.workspaceIndexing) {
|
|
1832
|
+
sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1288
1835
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1836
|
+
// #832: workspace-indexing servers that are classified as silent on
|
|
1837
|
+
// clean do not benefit from the generic cold-indexing floor. Their
|
|
1838
|
+
// configured strategy already gives the first sweep touch a bounded
|
|
1839
|
+
// workspace-index budget (marksman: 1500ms), while the capability
|
|
1840
|
+
// classification proves that a clean push has no affirmative signal to
|
|
1841
|
+
// wait for. Keep this restricted to the workspace-indexing strategy:
|
|
1842
|
+
// TypeScript is also a silent-on-clean push server, but its cold project
|
|
1843
|
+
// load still needs the longer 20s floor.
|
|
1844
|
+
//
|
|
1845
|
+
// Build this from the live spawned client's capabilities rather than
|
|
1846
|
+
// server id alone. Missing/throwing capability data fails closed, so a
|
|
1847
|
+
// new or ambiguous server keeps the existing generous warm-up budget.
|
|
1848
|
+
const silentCleanWarmupServers = new Set();
|
|
1849
|
+
if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
|
|
1850
|
+
for (const entry of spawned) {
|
|
1851
|
+
const strategy = getStrategy(entry.client.serverId);
|
|
1852
|
+
if (strategy.workspaceIndexing !== true)
|
|
1853
|
+
continue;
|
|
1854
|
+
try {
|
|
1855
|
+
const snapshot = {
|
|
1856
|
+
serverId: entry.client.serverId,
|
|
1857
|
+
root: entry.client.root,
|
|
1858
|
+
operationSupport: entry.client.getOperationSupport(),
|
|
1859
|
+
workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
|
|
1860
|
+
advertisedCommands: entry.client.getAdvertisedCommands(),
|
|
1861
|
+
rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
|
|
1862
|
+
launchVariant: entry.client.getLaunchVariant?.(),
|
|
1863
|
+
};
|
|
1864
|
+
if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
|
|
1865
|
+
"tier3-silent") {
|
|
1866
|
+
silentCleanWarmupServers.add(entry.client.serverId);
|
|
1867
|
+
}
|
|
1301
1868
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
if ((options.warmupAttempt ?? 1) > 1) {
|
|
1305
|
-
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1869
|
+
catch {
|
|
1870
|
+
// Fail closed: capability uncertainty must retain the cold floor.
|
|
1306
1871
|
}
|
|
1307
|
-
return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
|
|
1308
1872
|
}
|
|
1309
|
-
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1310
1873
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
//
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1874
|
+
// Each server gets its OWN deadline, bounded by the caller cap as a
|
|
1875
|
+
// CEILING (never a floor) — so a clean push-silent primary (typescript
|
|
1876
|
+
// ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
|
|
1877
|
+
// slow aux (opengrep) can't override the per-edit cap. Resolves as soon
|
|
1878
|
+
// as a server publishes; this is just its individual deadline. (#242)
|
|
1879
|
+
const perServerTimeout = (serverId) => {
|
|
1880
|
+
const strategy = getStrategy(serverId);
|
|
1881
|
+
let strategyWait = strategy.aggregateWaitMs;
|
|
1882
|
+
// #645: a `workspaceIndexing` server (marksman) only needs the
|
|
1883
|
+
// full budget for the FIRST same-sweep touch to it — every
|
|
1884
|
+
// subsequent touch in this sweep uses the much shorter warm-wait
|
|
1885
|
+
// instead, since the one-time index build only needs to finish
|
|
1886
|
+
// once. `sweepFirstTouch` only has entries when a sweep gate was
|
|
1887
|
+
// passed in AND the strategy is marked, so a per-edit touch
|
|
1888
|
+
// (no gate) or an unmarked server is completely unaffected.
|
|
1889
|
+
const isFirstTouch = sweepFirstTouch.get(serverId);
|
|
1890
|
+
if (isFirstTouch === false && strategy.workspaceIndexing) {
|
|
1891
|
+
strategyWait =
|
|
1892
|
+
strategy.workspaceIndexingWarmWaitMs ??
|
|
1893
|
+
Math.min(300, strategyWait);
|
|
1894
|
+
}
|
|
1895
|
+
if (callerCap !== undefined) {
|
|
1896
|
+
// #669: `ensureWarmForSweep`'s cold-server warm-up wants its cap
|
|
1897
|
+
// to act as a FLOOR (give it at least this much, possibly more
|
|
1898
|
+
// if the strategy already wants more) rather than the normal
|
|
1899
|
+
// ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
|
|
1900
|
+
if (options.warmupOverride) {
|
|
1901
|
+
// #832: a workspace-indexing server already classified as
|
|
1902
|
+
// silent-on-clean uses its strategy's bounded wait on the first
|
|
1903
|
+
// attempt; the generic cold floor is for servers whose cold work
|
|
1904
|
+
// can eventually produce a push answer (notably TypeScript).
|
|
1905
|
+
if (silentCleanWarmupServers.has(serverId)) {
|
|
1906
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1907
|
+
}
|
|
1908
|
+
// #799: only the FIRST warm-up attempt for a cold server gets the
|
|
1909
|
+
// floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
|
|
1910
|
+
if ((options.warmupAttempt ?? 1) > 1) {
|
|
1911
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1912
|
+
}
|
|
1913
|
+
return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
|
|
1914
|
+
}
|
|
1915
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1916
|
+
}
|
|
1917
|
+
return strategyWait > 0 ? strategyWait : modeFloor;
|
|
1918
|
+
};
|
|
1919
|
+
let timeoutFor;
|
|
1920
|
+
if (envWait !== undefined) {
|
|
1921
|
+
// Env override is a single flat cap so users can tune without rebuilding.
|
|
1922
|
+
timeoutFor = () => envWait;
|
|
1354
1923
|
}
|
|
1355
|
-
|
|
1356
|
-
|
|
1924
|
+
else if ((!useAllClients && spawned.length === 1) ||
|
|
1925
|
+
clientScope === "with-auxiliary" ||
|
|
1926
|
+
clientScope === "all") {
|
|
1927
|
+
timeoutFor = perServerTimeout;
|
|
1357
1928
|
}
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
// have settled the auxiliaries get at most auxGraceMs before we proceed.
|
|
1364
|
-
// Primary-only and "all"/"primary" scopes are completely unaffected —
|
|
1365
|
-
// they fall through to the original Promise.all path below.
|
|
1366
|
-
//
|
|
1367
|
-
// "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
|
|
1368
|
-
// In the with-auxiliary spawn list, `getClientForFile` returns the
|
|
1369
|
-
// language-primary entry first and `getAuxiliaryClientsForFile` appends
|
|
1370
|
-
// the rest — but we use info.role rather than position so the logic is
|
|
1371
|
-
// correct even if ordering shifts in the future.
|
|
1372
|
-
//
|
|
1373
|
-
// The #707 tsserver sync race operates exclusively on single-server
|
|
1374
|
-
// primary-scope touches (guarded by `clientScope === "primary" &&
|
|
1375
|
-
// spawned.length === 1`), so there is NO interaction with this path.
|
|
1376
|
-
const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
|
|
1377
|
-
spawned.some((e) => e.info.role === "auxiliary");
|
|
1378
|
-
// Per-server wait promises (each already bounded by its own
|
|
1379
|
-
// perServerTimeout — unchanged from before R8).
|
|
1380
|
-
let pressureSnapshots = [];
|
|
1381
|
-
if (shouldPreferPullOnlyDiagnostics()) {
|
|
1382
|
-
try {
|
|
1383
|
-
pressureSnapshots = await this.getCapabilitySnapshots(filePath);
|
|
1929
|
+
else {
|
|
1930
|
+
// Fail-safe for any future clientScope this branch hasn't been
|
|
1931
|
+
// taught about yet — keep the old flat resolution rather than
|
|
1932
|
+
// silently mis-budgeting an unrecognized scope.
|
|
1933
|
+
timeoutFor = () => callerCap ?? modeFloor;
|
|
1384
1934
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1935
|
+
// Detection deadline = the slowest individual server's budget.
|
|
1936
|
+
const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
|
|
1937
|
+
// #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
|
|
1938
|
+
// starts. Cheap synchronous gates first (notify succeeded, collecting,
|
|
1939
|
+
// primary scope, `serverId === "typescript"` — the sync commands this
|
|
1940
|
+
// races are tsserver-specific protocol extensions, not a generic
|
|
1941
|
+
// push-only capability, so #799 giving other servers (marksman) the
|
|
1942
|
+
// SAME `silentOnClean` marker must not route them into a sync attempt
|
|
1943
|
+
// that can never succeed for them), then the live capability-snapshot
|
|
1944
|
+
// tier classification (`classifyCascadeWaitTier`, which also excludes
|
|
1945
|
+
// native-ts7 via `launchVariant`). Every other server fails this
|
|
1946
|
+
// synchronous gate and pays ZERO extra work — not even the snapshot
|
|
1947
|
+
// read; a non-typescript `silentOnClean` server instead gets the
|
|
1948
|
+
// generic (non-racing) clean-confirm fallback further below.
|
|
1949
|
+
if (!notifyWriteTimedOut &&
|
|
1950
|
+
options.collectDiagnostics === true &&
|
|
1951
|
+
clientScope === "primary" &&
|
|
1952
|
+
spawned.length === 1 &&
|
|
1953
|
+
spawned[0].client.serverId === "typescript" &&
|
|
1954
|
+
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1955
|
+
try {
|
|
1956
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
1957
|
+
tsserverSyncEligible =
|
|
1958
|
+
classifyCascadeWaitTier(this, filePath, snapshots) ===
|
|
1959
|
+
"tier3-silent";
|
|
1960
|
+
}
|
|
1961
|
+
catch {
|
|
1962
|
+
// Fail-safe: ineligible — today's full wait, no sync attempt.
|
|
1963
|
+
}
|
|
1387
1964
|
}
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1965
|
+
const waitStartedAt = Date.now();
|
|
1966
|
+
// R8 (#714): on the with-auxiliary path, apply a bounded aux grace so a
|
|
1967
|
+
// slow auxiliary no longer holds the push wait to its own deadline.
|
|
1968
|
+
// Primary waits resolve on their own per-server budget; once ALL primaries
|
|
1969
|
+
// have settled the auxiliaries get at most auxGraceMs before we proceed.
|
|
1970
|
+
// Primary-only and "all"/"primary" scopes are completely unaffected —
|
|
1971
|
+
// they fall through to the original Promise.all path below.
|
|
1972
|
+
//
|
|
1973
|
+
// "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
|
|
1974
|
+
// In the with-auxiliary spawn list, `getClientForFile` returns the
|
|
1975
|
+
// language-primary entry first and `getAuxiliaryClientsForFile` appends
|
|
1976
|
+
// the rest — but we use info.role rather than position so the logic is
|
|
1977
|
+
// correct even if ordering shifts in the future.
|
|
1978
|
+
//
|
|
1979
|
+
// The #707 tsserver sync race operates exclusively on single-server
|
|
1980
|
+
// primary-scope touches (guarded by `clientScope === "primary" &&
|
|
1981
|
+
// spawned.length === 1`), so there is NO interaction with this path.
|
|
1982
|
+
const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
|
|
1983
|
+
spawned.some((e) => e.info.role === "auxiliary");
|
|
1984
|
+
// Per-server wait promises (each already bounded by its own
|
|
1985
|
+
// perServerTimeout — unchanged from before R8).
|
|
1986
|
+
let pressureSnapshots = [];
|
|
1987
|
+
if (shouldPreferPullOnlyDiagnostics()) {
|
|
1988
|
+
try {
|
|
1989
|
+
pressureSnapshots = await this.getCapabilitySnapshots(filePath);
|
|
1990
|
+
}
|
|
1991
|
+
catch {
|
|
1992
|
+
// Fail-open: missing capability state keeps today's push fallback.
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
const perServerWaits = spawned.map((entry) => {
|
|
1996
|
+
const serverTimeout = timeoutFor(entry.client.serverId);
|
|
1997
|
+
const baseline = diagnosticBaselines.get(entry.client);
|
|
1998
|
+
const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
|
|
1999
|
+
// #743: per-server — a server we DID push to still gets the
|
|
2000
|
+
// version-baseline wait even when a sibling was debounced away.
|
|
2001
|
+
const wait = !notifySkippedServerIds.has(entry.info.id) && Number.isFinite(baseline)
|
|
1399
2002
|
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
1400
|
-
|
|
2003
|
+
minVersion: baseline,
|
|
2004
|
+
...(pullOnly && { pullOnly: true }),
|
|
1401
2005
|
})
|
|
1402
|
-
:
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
:
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
if (
|
|
1432
|
-
|
|
2006
|
+
: pullOnly
|
|
2007
|
+
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
2008
|
+
pullOnly: true,
|
|
2009
|
+
})
|
|
2010
|
+
: entry.client.waitForDiagnostics(filePath, serverTimeout);
|
|
2011
|
+
return wait.catch(() => undefined);
|
|
2012
|
+
});
|
|
2013
|
+
// The push wait — same per-server budget composition as before #707;
|
|
2014
|
+
// only the awaiting changed (assigned so it can be raced below).
|
|
2015
|
+
let pushWaitSettled = false;
|
|
2016
|
+
const pushWait = hasTouchAuxiliaries
|
|
2017
|
+
? (() => {
|
|
2018
|
+
// Primary waits: all non-auxiliary servers.
|
|
2019
|
+
const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
|
|
2020
|
+
// Aux waits: auxiliary servers (advisory).
|
|
2021
|
+
const auxWaits = perServerWaits
|
|
2022
|
+
.map((p, i) => spawned[i].info.role === "auxiliary"
|
|
2023
|
+
? { promise: p, serverId: spawned[i].info.id }
|
|
2024
|
+
: null)
|
|
2025
|
+
.filter((x) => x !== null);
|
|
2026
|
+
const auxGraceMs = readEnvAuxGraceMs() ?? 500;
|
|
2027
|
+
// After all primaries settle, give auxiliaries at most auxGraceMs.
|
|
2028
|
+
// Late aux results are dropped from the wait (advisory only — they
|
|
2029
|
+
// land in the client cache and surface on the next edit); aux servers
|
|
2030
|
+
// that did answer within the grace are included automatically since
|
|
2031
|
+
// their waitForDiagnostics already resolved. The cut-off server ids
|
|
2032
|
+
// are logged in the latency metadata (lsp_touch_file phase, field
|
|
2033
|
+
// `auxCutOffServerIds`).
|
|
2034
|
+
return Promise.all(primaryWaits).then(async () => {
|
|
2035
|
+
if (auxWaits.length === 0)
|
|
2036
|
+
return;
|
|
2037
|
+
const auxTimeout = new Promise((resolve) => {
|
|
2038
|
+
const t = setTimeout(() => resolve("timeout"), auxGraceMs);
|
|
2039
|
+
if (typeof t === "object" && "unref" in t)
|
|
2040
|
+
t.unref?.();
|
|
2041
|
+
});
|
|
2042
|
+
const auxAll = Promise.all(auxWaits.map((a) => a.promise)).then(() => "done");
|
|
2043
|
+
const outcome = await Promise.race([auxAll, auxTimeout]);
|
|
2044
|
+
if (outcome === "timeout") {
|
|
2045
|
+
// Record which auxiliaries did NOT finish in time.
|
|
2046
|
+
const unfinished = [];
|
|
2047
|
+
for (const a of auxWaits) {
|
|
2048
|
+
let done = false;
|
|
2049
|
+
// Check synchronously if already resolved by racing against
|
|
2050
|
+
// a resolved promise.
|
|
2051
|
+
await Promise.race([
|
|
2052
|
+
a.promise.then(() => {
|
|
2053
|
+
done = true;
|
|
2054
|
+
}),
|
|
2055
|
+
Promise.resolve(),
|
|
2056
|
+
]);
|
|
2057
|
+
if (!done)
|
|
2058
|
+
unfinished.push(a.serverId);
|
|
2059
|
+
}
|
|
2060
|
+
if (unfinished.length > 0)
|
|
2061
|
+
auxCutOffServerIds = unfinished;
|
|
2062
|
+
}
|
|
1433
2063
|
});
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
2064
|
+
})()
|
|
2065
|
+
: Promise.all(perServerWaits).then(() => { });
|
|
2066
|
+
pushWait.then(() => {
|
|
2067
|
+
pushWaitSettled = true;
|
|
2068
|
+
});
|
|
2069
|
+
if (tsserverSyncEligible) {
|
|
2070
|
+
// #707 racing variant: rather than burning the full push-wait budget
|
|
2071
|
+
// on a silent-on-clean server (which by definition never answers on a
|
|
2072
|
+
// clean file), race the push wait against a grace-delayed sync
|
|
2073
|
+
// confirm. The grace (default 300ms, PI_LENS_TSSERVER_SYNC_GRACE_MS)
|
|
2074
|
+
// gives a genuinely dirty file's push a head start: if diagnostics
|
|
2075
|
+
// arrive before the grace elapses, the sync request never goes out —
|
|
2076
|
+
// zero new latency or requests on the push-answers path.
|
|
2077
|
+
//
|
|
2078
|
+
// Race semantics:
|
|
2079
|
+
// - sync answers first → that's the confirmed result (clean OR
|
|
2080
|
+
// dirty — the sync commands return the file's real syntactic +
|
|
2081
|
+
// semantic state, so a dirty-file win is still correct and its
|
|
2082
|
+
// findings are surfaced, never discarded).
|
|
2083
|
+
// - push settles first → push wins; a still-in-flight sync outcome
|
|
2084
|
+
// is discarded (the racer checks `pushWaitSettled` after the
|
|
2085
|
+
// call returns and drops its own result).
|
|
2086
|
+
// - sync unavailable/fails → the racer parks on a never-resolving
|
|
2087
|
+
// promise so the race is decided by the push wait's own budget,
|
|
2088
|
+
// exactly today's behavior (the end-of-wait fallback below still
|
|
2089
|
+
// gets its shot on a timed-out empty result).
|
|
2090
|
+
// The racer never rejects (every failure path is caught), so the
|
|
2091
|
+
// losing promise can never surface as an unhandled rejection.
|
|
2092
|
+
const graceMs = readTsserverSyncGraceMs();
|
|
2093
|
+
const primaryClient = spawned[0].client;
|
|
2094
|
+
// Resolves with the sync commands' diagnostics when the confirm
|
|
2095
|
+
// succeeds; parks on a never-resolving promise on EVERY other path
|
|
2096
|
+
// (push already answered, sync unavailable/failed, push won while
|
|
2097
|
+
// in flight) so the race is then decided by the push wait's own
|
|
2098
|
+
// budget — exactly today's behavior.
|
|
2099
|
+
const syncRacer = (async () => {
|
|
2100
|
+
await new Promise((resolve) => {
|
|
2101
|
+
const timer = setTimeout(resolve, graceMs);
|
|
2102
|
+
timer.unref?.();
|
|
2103
|
+
});
|
|
2104
|
+
// Push already answered (settled, or published diagnostics that
|
|
2105
|
+
// its wait is about to settle on) — nothing to confirm, no sync
|
|
2106
|
+
// request goes out.
|
|
2107
|
+
if (pushWaitSettled ||
|
|
2108
|
+
primaryClient.getDiagnostics(filePath).length > 0) {
|
|
2109
|
+
return new Promise(() => { });
|
|
2110
|
+
}
|
|
2111
|
+
try {
|
|
2112
|
+
const result = await attemptTsserverSyncDiagnostics(filePath, this);
|
|
2113
|
+
if (result === undefined || pushWaitSettled) {
|
|
2114
|
+
// Sync unavailable/failed, or push won while the sync call
|
|
2115
|
+
// was in flight — drop the sync outcome and let the push
|
|
2116
|
+
// wait decide the race.
|
|
2117
|
+
return new Promise(() => { });
|
|
1451
2118
|
}
|
|
1452
|
-
|
|
1453
|
-
auxCutOffServerIds = unfinished;
|
|
2119
|
+
return result;
|
|
1454
2120
|
}
|
|
2121
|
+
catch {
|
|
2122
|
+
return new Promise(() => { });
|
|
2123
|
+
}
|
|
2124
|
+
})();
|
|
2125
|
+
const raceOutcome = await Promise.race([
|
|
2126
|
+
pushWait.then(() => undefined),
|
|
2127
|
+
syncRacer,
|
|
2128
|
+
]);
|
|
2129
|
+
if (raceOutcome !== undefined) {
|
|
2130
|
+
tsserverSyncConfirmed = raceOutcome;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
else {
|
|
2134
|
+
await pushWait;
|
|
2135
|
+
}
|
|
2136
|
+
const waitedMs = Date.now() - waitStartedAt;
|
|
2137
|
+
if (tsserverSyncConfirmed !== undefined) {
|
|
2138
|
+
// #707: the racing sync confirm won — a definitive answer well under
|
|
2139
|
+
// the push-wait budget. Not a timeout, not inconclusive.
|
|
2140
|
+
logLatency({
|
|
2141
|
+
type: "phase",
|
|
2142
|
+
phase: "lsp_tsserver_sync_confirm",
|
|
2143
|
+
filePath: normalizedPath,
|
|
2144
|
+
durationMs: waitedMs,
|
|
2145
|
+
metadata: {
|
|
2146
|
+
source,
|
|
2147
|
+
serverId: spawned[0]?.client.serverId,
|
|
2148
|
+
clientScope,
|
|
2149
|
+
diagnosticsMode,
|
|
2150
|
+
mode: "race",
|
|
2151
|
+
confirmedDiagnosticCount: tsserverSyncConfirmed.length,
|
|
2152
|
+
budgetMs: timeoutMs,
|
|
2153
|
+
savedVsBudgetMs: Math.max(0, timeoutMs - waitedMs),
|
|
2154
|
+
},
|
|
1455
2155
|
});
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
// semantic state, so a dirty-file win is still correct and its
|
|
1474
|
-
// findings are surfaced, never discarded).
|
|
1475
|
-
// - push settles first → push wins; a still-in-flight sync outcome
|
|
1476
|
-
// is discarded (the racer checks `pushWaitSettled` after the
|
|
1477
|
-
// call returns and drops its own result).
|
|
1478
|
-
// - sync unavailable/fails → the racer parks on a never-resolving
|
|
1479
|
-
// promise so the race is decided by the push wait's own budget,
|
|
1480
|
-
// exactly today's behavior (the end-of-wait fallback below still
|
|
1481
|
-
// gets its shot on a timed-out empty result).
|
|
1482
|
-
// The racer never rejects (every failure path is caught), so the
|
|
1483
|
-
// losing promise can never surface as an unhandled rejection.
|
|
1484
|
-
const graceMs = readTsserverSyncGraceMs();
|
|
1485
|
-
const primaryClient = spawned[0].client;
|
|
1486
|
-
// Resolves with the sync commands' diagnostics when the confirm
|
|
1487
|
-
// succeeds; parks on a never-resolving promise on EVERY other path
|
|
1488
|
-
// (push already answered, sync unavailable/failed, push won while
|
|
1489
|
-
// in flight) so the race is then decided by the push wait's own
|
|
1490
|
-
// budget — exactly today's behavior.
|
|
1491
|
-
const syncRacer = (async () => {
|
|
1492
|
-
await new Promise((resolve) => {
|
|
1493
|
-
const timer = setTimeout(resolve, graceMs);
|
|
1494
|
-
timer.unref?.();
|
|
1495
|
-
});
|
|
1496
|
-
// Push already answered (settled, or published diagnostics that
|
|
1497
|
-
// its wait is about to settle on) — nothing to confirm, no sync
|
|
1498
|
-
// request goes out.
|
|
1499
|
-
if (pushWaitSettled ||
|
|
1500
|
-
primaryClient.getDiagnostics(filePath).length > 0) {
|
|
1501
|
-
return new Promise(() => { });
|
|
2156
|
+
}
|
|
2157
|
+
else if (waitedMs + 20 >= timeoutMs) {
|
|
2158
|
+
// Within ~20 ms of the configured budget we treat it as a timeout;
|
|
2159
|
+
// the LSP didn't beat the cap. Diagnostics that arrive late still
|
|
2160
|
+
// land in the client's cache and surface on the next edit.
|
|
2161
|
+
diagnosticsTimedOut = true;
|
|
2162
|
+
for (const entry of spawned) {
|
|
2163
|
+
incrementDegradationCount({
|
|
2164
|
+
kind: "lsp-diagnostics-timeout",
|
|
2165
|
+
// `info.id` is the authoritative server identity carried by
|
|
2166
|
+
// every spawned entry. The client test doubles (and some
|
|
2167
|
+
// lightweight clients) need not expose a serverId property;
|
|
2168
|
+
// ledger recording must never abort the touch or alter #570's
|
|
2169
|
+
// timeout-preserves-last-known-diagnostics semantics.
|
|
2170
|
+
subject: entry.info.id,
|
|
2171
|
+
reason: "diagnostics wait timed out",
|
|
2172
|
+
});
|
|
1502
2173
|
}
|
|
2174
|
+
logLatency({
|
|
2175
|
+
type: "phase",
|
|
2176
|
+
phase: "lsp_diagnostics_timeout",
|
|
2177
|
+
filePath: normalizedPath,
|
|
2178
|
+
durationMs: waitedMs,
|
|
2179
|
+
metadata: {
|
|
2180
|
+
source,
|
|
2181
|
+
clientScope,
|
|
2182
|
+
diagnosticsMode,
|
|
2183
|
+
timeoutMs,
|
|
2184
|
+
},
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
// #814: capability-aware AGGREGATE wait — generalize #799's
|
|
2188
|
+
// single-server (`clientScope === "primary" && spawned.length === 1`)
|
|
2189
|
+
// silent-clean confirm to multi-server `clientScope: "all"` touches
|
|
2190
|
+
// (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
|
|
2191
|
+
// `serverScope: "all"`). #799's gate never fires here (it's scoped to
|
|
2192
|
+
// the primary hot path), so a scope-"all" touch where every OTHER
|
|
2193
|
+
// spawned server already answered but one push-only `silentOnClean`
|
|
2194
|
+
// server (marksman on a clean markdown file) never publishes still
|
|
2195
|
+
// reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
|
|
2196
|
+
// though the "silence" is exactly what that server's own known
|
|
2197
|
+
// clean-behavior predicts — not an unresolved question.
|
|
2198
|
+
//
|
|
2199
|
+
// A spawned server counts as "still outstanding" when nothing landed
|
|
2200
|
+
// in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
|
|
2201
|
+
// is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
|
|
2202
|
+
// that file's entry as part of the didOpen/didChange this touch just
|
|
2203
|
+
// sent, so a present entry can only be a FRESH answer (found or a real
|
|
2204
|
+
// confirmed-empty push/pull), never a stale one bleeding through from
|
|
2205
|
+
// an earlier touch. This is the same "did anything publish for this
|
|
2206
|
+
// file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
|
|
2207
|
+
// already trusts (#240 doctrine) — reused here, not reinvented.
|
|
2208
|
+
//
|
|
2209
|
+
// The touch stays inconclusive unless EVERY still-outstanding server
|
|
2210
|
+
// is classified `tier3-silent` (push-only + `silentOnClean`, the same
|
|
2211
|
+
// `classifyServerWaitTier` rule the single-server gate below and the
|
|
2212
|
+
// cascade lane use) — one ordinary push-only straggler (still
|
|
2213
|
+
// genuinely analyzing) or a pull-capable server that never answered
|
|
2214
|
+
// keeps the touch cautious, matching #799's "err toward caution"
|
|
2215
|
+
// posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
|
|
2216
|
+
// plus the per-server re-check below are the same "the notify write
|
|
2217
|
+
// must have actually landed" conservatism #799 established — a
|
|
2218
|
+
// server's silence is only evidence of "clean" when we know it saw
|
|
2219
|
+
// the new content.
|
|
2220
|
+
if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
|
|
1503
2221
|
try {
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
2222
|
+
const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
|
|
2223
|
+
!entry.client.getAllDiagnostics().has(normalizedPath));
|
|
2224
|
+
if (outstanding.length > 0) {
|
|
2225
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
2226
|
+
const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
|
|
2227
|
+
if (allSilent) {
|
|
2228
|
+
// #1277: the static tier3-silent classification alone can't
|
|
2229
|
+
// tell a genuinely clean server from one that accepted the
|
|
2230
|
+
// notify write and then wedged — both look identical to
|
|
2231
|
+
// `classifyServerWaitTier`, which only reads the capability
|
|
2232
|
+
// snapshot, never the server's actual current responsiveness.
|
|
2233
|
+
// Require every still-outstanding server to answer a cheap
|
|
2234
|
+
// bounded round-trip before trusting the silence as clean;
|
|
2235
|
+
// any server that doesn't respond in time keeps the touch
|
|
2236
|
+
// inconclusive rather than confirming a possibly-dead server
|
|
2237
|
+
// clean.
|
|
2238
|
+
const liveness = await Promise.all(outstanding.map((entry) => (entry.client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false)));
|
|
2239
|
+
if (liveness.every(Boolean)) {
|
|
2240
|
+
diagnosticsTimedOut = false;
|
|
2241
|
+
logLatency({
|
|
2242
|
+
type: "phase",
|
|
2243
|
+
phase: "lsp_silent_clean_confirm",
|
|
2244
|
+
filePath: normalizedPath,
|
|
2245
|
+
durationMs: Date.now() - startedAt,
|
|
2246
|
+
metadata: {
|
|
2247
|
+
source,
|
|
2248
|
+
clientScope,
|
|
2249
|
+
diagnosticsMode,
|
|
2250
|
+
aggregate: true,
|
|
2251
|
+
serverIds: outstanding.map((entry) => entry.client.serverId),
|
|
2252
|
+
},
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
1510
2256
|
}
|
|
1511
|
-
return result;
|
|
1512
2257
|
}
|
|
1513
2258
|
catch {
|
|
1514
|
-
|
|
2259
|
+
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's
|
|
2260
|
+
// inconclusive behavior, exactly like the single-server gate.
|
|
1515
2261
|
}
|
|
1516
|
-
})();
|
|
1517
|
-
const raceOutcome = await Promise.race([
|
|
1518
|
-
pushWait.then(() => undefined),
|
|
1519
|
-
syncRacer,
|
|
1520
|
-
]);
|
|
1521
|
-
if (raceOutcome !== undefined) {
|
|
1522
|
-
tsserverSyncConfirmed = raceOutcome;
|
|
1523
2262
|
}
|
|
1524
2263
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
2264
|
+
// #707: when the racing sync confirm won the wait, its answer IS the
|
|
2265
|
+
// collected result — the file's real syntactic + semantic state straight
|
|
2266
|
+
// from tsserver (clean = [], dirty = real findings that a silentOnClean
|
|
2267
|
+
// server had computed but never published). Otherwise merge the push
|
|
2268
|
+
// diagnostics from the client cache as always.
|
|
2269
|
+
let collected = options.collectDiagnostics
|
|
2270
|
+
? tsserverSyncConfirmed !== undefined
|
|
2271
|
+
? mergeLspDiagnostics(tsserverSyncConfirmed)
|
|
2272
|
+
: mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
|
|
2273
|
+
: undefined;
|
|
2274
|
+
// #1095 (P3-b): whether `collected` came from a tsserver sync confirm
|
|
2275
|
+
// (`tsserverSyncRequest`) rather than the publish cache. A sync-confirmed
|
|
2276
|
+
// result is authoritative for the CURRENT buffer but is NOT tied to the
|
|
2277
|
+
// publish-path content binding (`diagnosticBindings`, set on publish), so
|
|
2278
|
+
// composing that binding here could let a STALE publish fingerprint demote a
|
|
2279
|
+
// genuinely-fresh sync answer to `false`. The end-of-wait fallback below can
|
|
2280
|
+
// also set this. When true, the binding is surfaced as "unknown" (honest,
|
|
2281
|
+
// non-demoting) rather than the stale publish binding.
|
|
2282
|
+
let syncConfirmed = tsserverSyncConfirmed !== undefined;
|
|
2283
|
+
// #707 end-of-wait fallback: when the racing confirm did NOT decide the
|
|
2284
|
+
// wait (sync unavailable/failed mid-race, or push resolved as a bare
|
|
2285
|
+
// timeout) and the wait timed out with an empty result on an eligible
|
|
2286
|
+
// touch, give the sync clean-confirm one last shot before reporting
|
|
2287
|
+
// inconclusive. `tsserverSyncEligible` already encodes every gate (notify
|
|
2288
|
+
// succeeded, collecting, primary scope, tier3-silent classic typescript —
|
|
2289
|
+
// native-ts7 excluded by `classifyCascadeWaitTier`). If the sync call
|
|
2290
|
+
// answers (even with an empty body, which is a confirmed clean), we use
|
|
2291
|
+
// those diagnostics as the confirmed result and clear the
|
|
2292
|
+
// `diagnosticsTimedOut` flag so the touch is no longer treated as
|
|
2293
|
+
// inconclusive. Sync diagnostics on a dirty file are surfaced, not
|
|
2294
|
+
// discarded. If the sync call fails or is unavailable, we fall through to
|
|
2295
|
+
// today's behavior: `inconclusive` = true, `collected` unchanged. This
|
|
2296
|
+
// turns "unconfirmed after ~1000ms" into "confirmed at ~wait+sync-RTT"
|
|
2297
|
+
// even when the race path couldn't answer.
|
|
2298
|
+
if (diagnosticsTimedOut &&
|
|
2299
|
+
tsserverSyncEligible &&
|
|
2300
|
+
collected !== undefined &&
|
|
2301
|
+
collected.length === 0) {
|
|
2302
|
+
try {
|
|
2303
|
+
const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
|
|
2304
|
+
if (syncResult !== undefined) {
|
|
2305
|
+
// Sync answered — confirmed result (clean or with diagnostics).
|
|
2306
|
+
// Clear the timed-out flag so the touch is no longer inconclusive.
|
|
2307
|
+
diagnosticsTimedOut = false;
|
|
2308
|
+
syncConfirmed = true;
|
|
2309
|
+
collected = syncResult.length > 0
|
|
2310
|
+
? mergeLspDiagnostics(syncResult)
|
|
2311
|
+
: [];
|
|
2312
|
+
logLatency({
|
|
2313
|
+
type: "phase",
|
|
2314
|
+
phase: "lsp_tsserver_sync_confirm",
|
|
2315
|
+
filePath: normalizedPath,
|
|
2316
|
+
durationMs: Date.now() - startedAt,
|
|
2317
|
+
metadata: {
|
|
2318
|
+
source,
|
|
2319
|
+
clientScope,
|
|
2320
|
+
diagnosticsMode,
|
|
2321
|
+
mode: "end-of-wait",
|
|
2322
|
+
confirmedDiagnosticCount: collected.length,
|
|
2323
|
+
},
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
catch {
|
|
2328
|
+
// Any failure here falls through to today's inconclusive behavior.
|
|
2329
|
+
}
|
|
1565
2330
|
}
|
|
1566
|
-
// #
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
// the
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
//
|
|
2331
|
+
// #799: generalize the "silent-clean push-only" confirm beyond
|
|
2332
|
+
// typescript's active sync-command race above. That mechanism is
|
|
2333
|
+
// TS-specific (`attemptTsserverSyncDiagnostics` races an actual
|
|
2334
|
+
// `typescript.tsserverRequest` — no equivalent protocol exists for
|
|
2335
|
+
// e.g. marksman) and is now scoped to `serverId === "typescript"`
|
|
2336
|
+
// only (see the gate above), so it never fires for another
|
|
2337
|
+
// `silentOnClean` server. This is the generic fallback for those
|
|
2338
|
+
// servers: if the wait ran its full budget with a successful notify
|
|
2339
|
+
// write and nothing published, and the live capability snapshot
|
|
2340
|
+
// classifies this touch as tier3-silent (push-only + `silentOnClean`,
|
|
2341
|
+
// #458's `classifyCascadeWaitTier`), that is not "still working" —
|
|
2342
|
+
// `silentOnClean` means by definition this server publishes NOTHING
|
|
2343
|
+
// on a clean transition, so a timeout under those conditions IS the
|
|
2344
|
+
// confirmed-clean answer. `!tsserverSyncEligible` keeps this from
|
|
2345
|
+
// ever double-deciding typescript's own touches — when the sync race
|
|
2346
|
+
// was attempted and failed/was unavailable, typescript's existing
|
|
2347
|
+
// "falls through to inconclusive, unchanged" contract (#707) is
|
|
2348
|
+
// preserved exactly; typescript touches that never enter that gate
|
|
2349
|
+
// (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
|
|
2350
|
+
// warm-up call) are still eligible here as a genuine bonus fix. Scoped
|
|
2351
|
+
// to `clientScope === "primary"`/`spawned.length === 1` exactly like
|
|
2352
|
+
// the sync-eligible gate above (and like `ensureWarmForSweep`'s own
|
|
2353
|
+
// `clientScope: "primary"` warm-up touch) so a multi-server
|
|
2354
|
+
// with-auxiliary/all touch — where a partial timeout must stay
|
|
2355
|
+
// cautious per the doc below — is never affected.
|
|
1577
2356
|
//
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
// genuinely analyzing) or a pull-capable server that never answered
|
|
1593
|
-
// keeps the touch cautious, matching #799's "err toward caution"
|
|
1594
|
-
// posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
|
|
1595
|
-
// plus the per-server re-check below are the same "the notify write
|
|
1596
|
-
// must have actually landed" conservatism #799 established — a
|
|
1597
|
-
// server's silence is only evidence of "clean" when we know it saw
|
|
1598
|
-
// the new content.
|
|
1599
|
-
if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
|
|
2357
|
+
// #814: this is now a SPECIAL CASE of the more general per-server gate
|
|
2358
|
+
// above (the `clientScope === "all"` block right before the diagnostics-
|
|
2359
|
+
// wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
|
|
2360
|
+
// server is tier3-silent" collapses to exactly this single-server check.
|
|
2361
|
+
// Left in place unchanged (rather than deleted/rewritten to delegate to
|
|
2362
|
+
// the new gate) per #814's scope: a future cleanup could fold this block
|
|
2363
|
+
// into the general one once both have soaked, but that's a separate,
|
|
2364
|
+
// lower-risk follow-up, not bundled into this fix.
|
|
2365
|
+
if (diagnosticsTimedOut &&
|
|
2366
|
+
!notifyWriteTimedOut &&
|
|
2367
|
+
!tsserverSyncEligible &&
|
|
2368
|
+
clientScope === "primary" &&
|
|
2369
|
+
spawned.length === 1 &&
|
|
2370
|
+
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1600
2371
|
try {
|
|
1601
|
-
const
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
2372
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
2373
|
+
if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
|
|
2374
|
+
// #1277: same liveness precondition as the aggregate gate above —
|
|
2375
|
+
// `tier3-silent` is a static capability classification and can't
|
|
2376
|
+
// distinguish "silent because clean" from "silent because wedged".
|
|
2377
|
+
// A cheap bounded round-trip proves the server is still actually
|
|
2378
|
+
// responding before its silence is trusted as a clean confirm; a
|
|
2379
|
+
// server that doesn't answer in time leaves the touch inconclusive.
|
|
2380
|
+
const alive = await (spawned[0].client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false);
|
|
2381
|
+
if (alive) {
|
|
1607
2382
|
diagnosticsTimedOut = false;
|
|
2383
|
+
if (collected !== undefined)
|
|
2384
|
+
collected = mergeLspDiagnostics([]);
|
|
1608
2385
|
logLatency({
|
|
1609
2386
|
type: "phase",
|
|
1610
2387
|
phase: "lsp_silent_clean_confirm",
|
|
@@ -1614,238 +2391,155 @@ export class LSPService {
|
|
|
1614
2391
|
source,
|
|
1615
2392
|
clientScope,
|
|
1616
2393
|
diagnosticsMode,
|
|
1617
|
-
|
|
1618
|
-
serverIds: outstanding.map((entry) => entry.client.serverId),
|
|
2394
|
+
serverId: spawned[0].client.serverId,
|
|
1619
2395
|
},
|
|
1620
2396
|
});
|
|
1621
2397
|
}
|
|
1622
2398
|
}
|
|
1623
2399
|
}
|
|
1624
2400
|
catch {
|
|
1625
|
-
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's
|
|
1626
|
-
//
|
|
2401
|
+
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
|
|
2402
|
+
// behavior.
|
|
1627
2403
|
}
|
|
1628
2404
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
:
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
collected !== undefined &&
|
|
1658
|
-
collected.length === 0) {
|
|
1659
|
-
try {
|
|
1660
|
-
const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
|
|
1661
|
-
if (syncResult !== undefined) {
|
|
1662
|
-
// Sync answered — confirmed result (clean or with diagnostics).
|
|
1663
|
-
// Clear the timed-out flag so the touch is no longer inconclusive.
|
|
1664
|
-
diagnosticsTimedOut = false;
|
|
1665
|
-
collected = syncResult.length > 0
|
|
1666
|
-
? mergeLspDiagnostics(syncResult)
|
|
1667
|
-
: [];
|
|
1668
|
-
logLatency({
|
|
1669
|
-
type: "phase",
|
|
1670
|
-
phase: "lsp_tsserver_sync_confirm",
|
|
1671
|
-
filePath: normalizedPath,
|
|
1672
|
-
durationMs: Date.now() - startedAt,
|
|
1673
|
-
metadata: {
|
|
1674
|
-
source,
|
|
1675
|
-
clientScope,
|
|
1676
|
-
diagnosticsMode,
|
|
1677
|
-
mode: "end-of-wait",
|
|
1678
|
-
confirmedDiagnosticCount: collected.length,
|
|
1679
|
-
},
|
|
1680
|
-
});
|
|
2405
|
+
// A touch is inconclusive when EITHER the notify write or the
|
|
2406
|
+
// diagnostics wait hit their deadline for ANY of the spawned servers
|
|
2407
|
+
// (these flags are touch-wide, covering the whole `Promise.all` over
|
|
2408
|
+
// `spawned` — see the field doc on the return type). We deliberately
|
|
2409
|
+
// err toward caution here: `collected` merges diagnostics across every
|
|
2410
|
+
// spawned server, so even a partial timeout (e.g. a slow auxiliary
|
|
2411
|
+
// while the primary answered) means the merged result may be missing
|
|
2412
|
+
// findings that just hadn't arrived yet — it must not be trusted as a
|
|
2413
|
+
// confirmed answer.
|
|
2414
|
+
const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
|
|
2415
|
+
// #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
|
|
2416
|
+
// "actually warm" signal `ensureWarmForSweep` waits for — mark every
|
|
2417
|
+
// spawned server so a later sweep in this session sees the check as a
|
|
2418
|
+
// no-op instead of paying the warm-up round trip again.
|
|
2419
|
+
//
|
|
2420
|
+
// #743: the diagnostics wait is a blanket (touch-wide) gate, but the
|
|
2421
|
+
// notify-write timeout is now PER-SERVER — a healthy server whose sibling's
|
|
2422
|
+
// write stalled must still be eligible, so only servers whose OWN write
|
|
2423
|
+
// timed out are skipped here (rather than gating the whole loop on the
|
|
2424
|
+
// file-level `inconclusive`).
|
|
2425
|
+
const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
|
|
2426
|
+
if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
|
|
2427
|
+
for (const entry of spawned) {
|
|
2428
|
+
if (notifyTimedOutServerIds.has(entry.info.id))
|
|
2429
|
+
continue;
|
|
2430
|
+
const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
2431
|
+
if (key)
|
|
2432
|
+
this.markDemonstratedReadyKey(key);
|
|
1681
2433
|
}
|
|
1682
2434
|
}
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
// ever double-deciding typescript's own touches — when the sync race
|
|
1702
|
-
// was attempted and failed/was unavailable, typescript's existing
|
|
1703
|
-
// "falls through to inconclusive, unchanged" contract (#707) is
|
|
1704
|
-
// preserved exactly; typescript touches that never enter that gate
|
|
1705
|
-
// (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
|
|
1706
|
-
// warm-up call) are still eligible here as a genuine bonus fix. Scoped
|
|
1707
|
-
// to `clientScope === "primary"`/`spawned.length === 1` exactly like
|
|
1708
|
-
// the sync-eligible gate above (and like `ensureWarmForSweep`'s own
|
|
1709
|
-
// `clientScope: "primary"` warm-up touch) so a multi-server
|
|
1710
|
-
// with-auxiliary/all touch — where a partial timeout must stay
|
|
1711
|
-
// cautious per the doc below — is never affected.
|
|
1712
|
-
//
|
|
1713
|
-
// #814: this is now a SPECIAL CASE of the more general per-server gate
|
|
1714
|
-
// above (the `clientScope === "all"` block right before the diagnostics-
|
|
1715
|
-
// wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
|
|
1716
|
-
// server is tier3-silent" collapses to exactly this single-server check.
|
|
1717
|
-
// Left in place unchanged (rather than deleted/rewritten to delegate to
|
|
1718
|
-
// the new gate) per #814's scope: a future cleanup could fold this block
|
|
1719
|
-
// into the general one once both have soaked, but that's a separate,
|
|
1720
|
-
// lower-risk follow-up, not bundled into this fix.
|
|
1721
|
-
if (diagnosticsTimedOut &&
|
|
1722
|
-
!notifyWriteTimedOut &&
|
|
1723
|
-
!tsserverSyncEligible &&
|
|
1724
|
-
clientScope === "primary" &&
|
|
1725
|
-
spawned.length === 1 &&
|
|
1726
|
-
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1727
|
-
try {
|
|
1728
|
-
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
1729
|
-
if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
|
|
1730
|
-
diagnosticsTimedOut = false;
|
|
1731
|
-
if (collected !== undefined)
|
|
1732
|
-
collected = mergeLspDiagnostics([]);
|
|
1733
|
-
logLatency({
|
|
1734
|
-
type: "phase",
|
|
1735
|
-
phase: "lsp_silent_clean_confirm",
|
|
1736
|
-
filePath: normalizedPath,
|
|
1737
|
-
durationMs: Date.now() - startedAt,
|
|
1738
|
-
metadata: {
|
|
1739
|
-
source,
|
|
1740
|
-
clientScope,
|
|
1741
|
-
diagnosticsMode,
|
|
1742
|
-
serverId: spawned[0].client.serverId,
|
|
1743
|
-
},
|
|
1744
|
-
});
|
|
2435
|
+
// Prime the last-known cache WITH the hash of the content we just synced,
|
|
2436
|
+
// so a hot-path consumer (actionable-warnings at turn_end) can verify the
|
|
2437
|
+
// cached diagnostics are for the current bytes before reusing them instead
|
|
2438
|
+
// of paying for a second open+wait. Only when we actually collected — a
|
|
2439
|
+
// non-collecting touch (didChange-only) leaves the prior entry intact.
|
|
2440
|
+
// Skip this entirely when the touch was inconclusive: an unconfirmed
|
|
2441
|
+
// empty `collected` must never erase a previously-confirmed non-empty
|
|
2442
|
+
// record (that's the #570 bug — a timeout silently reporting as clean
|
|
2443
|
+
// and wiping out known-good diagnostic state).
|
|
2444
|
+
if (collected !== undefined && !inconclusive) {
|
|
2445
|
+
const normalizedKey = normalizeMapKey(filePath);
|
|
2446
|
+
if (collected.length > 0) {
|
|
2447
|
+
this.lastKnownDiagnostics.set(normalizedKey, collected);
|
|
2448
|
+
this.lastKnownContentHash.set(normalizedKey, this.hashContent(content));
|
|
2449
|
+
}
|
|
2450
|
+
else {
|
|
2451
|
+
this.lastKnownDiagnostics.delete(normalizedKey);
|
|
2452
|
+
this.lastKnownContentHash.delete(normalizedKey);
|
|
1745
2453
|
}
|
|
1746
2454
|
}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
// findings that just hadn't arrived yet — it must not be trusted as a
|
|
1760
|
-
// confirmed answer.
|
|
1761
|
-
const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
|
|
1762
|
-
// #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
|
|
1763
|
-
// "actually warm" signal `ensureWarmForSweep` waits for — mark every
|
|
1764
|
-
// spawned server so a later sweep in this session sees the check as a
|
|
1765
|
-
// no-op instead of paying the warm-up round trip again.
|
|
1766
|
-
//
|
|
1767
|
-
// #743: the diagnostics wait is a blanket (touch-wide) gate, but the
|
|
1768
|
-
// notify-write timeout is now PER-SERVER — a healthy server whose sibling's
|
|
1769
|
-
// write stalled must still be eligible, so only servers whose OWN write
|
|
1770
|
-
// timed out are skipped here (rather than gating the whole loop on the
|
|
1771
|
-
// file-level `inconclusive`).
|
|
1772
|
-
const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
|
|
1773
|
-
if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
|
|
1774
|
-
for (const entry of spawned) {
|
|
1775
|
-
if (notifyTimedOutServerIds.has(entry.info.id))
|
|
1776
|
-
continue;
|
|
1777
|
-
const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1778
|
-
if (key)
|
|
1779
|
-
this.markDemonstratedReadyKey(key);
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
// Prime the last-known cache WITH the hash of the content we just synced,
|
|
1783
|
-
// so a hot-path consumer (actionable-warnings at turn_end) can verify the
|
|
1784
|
-
// cached diagnostics are for the current bytes before reusing them instead
|
|
1785
|
-
// of paying for a second open+wait. Only when we actually collected — a
|
|
1786
|
-
// non-collecting touch (didChange-only) leaves the prior entry intact.
|
|
1787
|
-
// Skip this entirely when the touch was inconclusive: an unconfirmed
|
|
1788
|
-
// empty `collected` must never erase a previously-confirmed non-empty
|
|
1789
|
-
// record (that's the #570 bug — a timeout silently reporting as clean
|
|
1790
|
-
// and wiping out known-good diagnostic state).
|
|
1791
|
-
if (collected !== undefined && !inconclusive) {
|
|
1792
|
-
const normalizedKey = normalizeMapKey(filePath);
|
|
1793
|
-
if (collected.length > 0) {
|
|
1794
|
-
this.lastKnownDiagnostics.set(normalizedKey, collected);
|
|
1795
|
-
this.lastKnownContentHash.set(normalizedKey, this.hashContent(content));
|
|
2455
|
+
// #1179 (shape-5 structural fix): build the result WRAPPER. The two flags
|
|
2456
|
+
// that used to ride the returned array as NON-enumerable side-channels
|
|
2457
|
+
// (`Object.defineProperty(collected, ...)`, dropped by any `[...]`/`.filter`/
|
|
2458
|
+
// `JSON` copy — the #1094/#1096 loss class) are now EXPLICIT ENUMERABLE
|
|
2459
|
+
// fields on this wrapper. `.diags` holds the array a copy operates on, so the
|
|
2460
|
+
// flags survive by construction. Field presence mirrors the old attachment
|
|
2461
|
+
// conditions EXACTLY: `inconclusive` only for a confirmed-inconclusive
|
|
2462
|
+
// collect, `binding` only for a collecting touch — a non-collecting touch
|
|
2463
|
+
// keeps resolving `{ diags: [] }`, no flags.
|
|
2464
|
+
const result = { diags: collected ?? [] };
|
|
2465
|
+
if (collected !== undefined && inconclusive) {
|
|
2466
|
+
result.inconclusive = true;
|
|
1796
2467
|
}
|
|
1797
|
-
else {
|
|
1798
|
-
|
|
1799
|
-
|
|
2468
|
+
else if (collected !== undefined) {
|
|
2469
|
+
// Preserve the lower-level affirmative result across consumers. In
|
|
2470
|
+
// particular, the silent-clean gates above clear diagnosticsTimedOut only
|
|
2471
|
+
// after a successful notify and capability-confirmed wait; reclassifying
|
|
2472
|
+
// that empty array later would discard the evidence that made it clean.
|
|
2473
|
+
result.confirmation = "confirmed";
|
|
1800
2474
|
}
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2475
|
+
// #1095: attach the merged content binding so a consumer can ask whether
|
|
2476
|
+
// these diagnostics were computed against current disk. Composed across every
|
|
2477
|
+
// spawned client so a single client whose view diverged from disk marks the
|
|
2478
|
+
// whole merged result mismatched. Disk verify is lazy + memoized per
|
|
2479
|
+
// (file, mtime).
|
|
2480
|
+
let binding;
|
|
2481
|
+
if (collected !== undefined) {
|
|
2482
|
+
binding = syncConfirmed
|
|
2483
|
+
? // #1095 (P3-b): a tsserver sync-confirmed result is authoritative for
|
|
2484
|
+
// the current buffer but not tied to the publish-path fingerprint —
|
|
2485
|
+
// surface "unknown" so a stale publish binding can't demote it.
|
|
2486
|
+
{ boundToCurrentDisk: "unknown" }
|
|
2487
|
+
: this.mergeBinding(filePath,
|
|
2488
|
+
// Optional-chain so a client without the getter (test doubles, a
|
|
2489
|
+
// partially-mocked client) yields "unknown" rather than throwing —
|
|
2490
|
+
// unknown preserves pre-#1095 behavior for that contributor.
|
|
2491
|
+
spawned.map((entry) => entry.client.getDiagnosticBinding?.(filePath)));
|
|
2492
|
+
result.binding = binding;
|
|
2493
|
+
}
|
|
2494
|
+
// The recent-touches entries are recorded per server inside the notify-write
|
|
2495
|
+
// loop above, at the moment each server's own write lands (#1253) — a server
|
|
2496
|
+
// whose write timed out or rejected deliberately gets none, so the next touch
|
|
2497
|
+
// re-pushes it rather than debouncing a failure into a later touch that looks
|
|
2498
|
+
// fully delivered. Nothing to record here: a skipped server keeps its original
|
|
2499
|
+
// entry (and timestamp) so its window still expires naturally instead of being
|
|
2500
|
+
// extended by every reuse.
|
|
2501
|
+
logLatency({
|
|
2502
|
+
type: "phase",
|
|
2503
|
+
phase: "lsp_touch_file",
|
|
2504
|
+
filePath: normalizedPath,
|
|
2505
|
+
durationMs: Date.now() - startedAt,
|
|
2506
|
+
metadata: {
|
|
2507
|
+
serverCountReady: spawned.length,
|
|
2508
|
+
clientScope,
|
|
2509
|
+
diagnosticsMode,
|
|
2510
|
+
source,
|
|
2511
|
+
failureKind: "success",
|
|
2512
|
+
collectedDiagnostics: collected?.length,
|
|
2513
|
+
// #1095: observability so an unbinding server is diagnosable —
|
|
2514
|
+
// "bound" (matches disk) / "mismatch" (diverged) / "unknown"
|
|
2515
|
+
// (version-less server or disk unreadable). Absent for non-collecting
|
|
2516
|
+
// touches (no merged result to bind).
|
|
2517
|
+
...(binding !== undefined && {
|
|
2518
|
+
bindingState: bindingStateLabel(binding.boundToCurrentDisk),
|
|
2519
|
+
}),
|
|
2520
|
+
notifySkipped,
|
|
2521
|
+
notifyWriteTimedOut,
|
|
2522
|
+
// #743: per-server detail — which servers' writes actually timed out.
|
|
2523
|
+
// Absent when none did. `notifyWriteTimedOut` is the file-level "at
|
|
2524
|
+
// least one" summary.
|
|
2525
|
+
...(notifyWriteTimedOutServerIds.length > 0 && {
|
|
2526
|
+
notifyWriteTimedOutServerIds,
|
|
2527
|
+
}),
|
|
2528
|
+
diagnosticsTimedOut,
|
|
2529
|
+
inconclusive,
|
|
2530
|
+
// R8 (#714): server ids of auxiliaries whose push wait was cut off by
|
|
2531
|
+
// the aux grace window (primary settled clean + aux timed out in grace).
|
|
2532
|
+
// Absent when no aux was cut off. These servers' diagnostics are
|
|
2533
|
+
// advisory-only and will surface on the next edit from their cache.
|
|
2534
|
+
...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
|
|
2535
|
+
},
|
|
1811
2536
|
});
|
|
2537
|
+
return result;
|
|
1812
2538
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
if (!notifySkipped) {
|
|
1817
|
-
this.markTouched(filePath, content, clientScope);
|
|
2539
|
+
finally {
|
|
2540
|
+
for (const key of leaseKeys)
|
|
2541
|
+
this.releaseClientLease(key);
|
|
1818
2542
|
}
|
|
1819
|
-
logLatency({
|
|
1820
|
-
type: "phase",
|
|
1821
|
-
phase: "lsp_touch_file",
|
|
1822
|
-
filePath: normalizedPath,
|
|
1823
|
-
durationMs: Date.now() - startedAt,
|
|
1824
|
-
metadata: {
|
|
1825
|
-
serverCountReady: spawned.length,
|
|
1826
|
-
clientScope,
|
|
1827
|
-
diagnosticsMode,
|
|
1828
|
-
source,
|
|
1829
|
-
failureKind: "success",
|
|
1830
|
-
collectedDiagnostics: collected?.length,
|
|
1831
|
-
notifySkipped,
|
|
1832
|
-
notifyWriteTimedOut,
|
|
1833
|
-
// #743: per-server detail — which servers' writes actually timed out.
|
|
1834
|
-
// Absent when none did. `notifyWriteTimedOut` is the file-level "at
|
|
1835
|
-
// least one" summary.
|
|
1836
|
-
...(notifyWriteTimedOutServerIds.length > 0 && {
|
|
1837
|
-
notifyWriteTimedOutServerIds,
|
|
1838
|
-
}),
|
|
1839
|
-
diagnosticsTimedOut,
|
|
1840
|
-
inconclusive,
|
|
1841
|
-
// R8 (#714): server ids of auxiliaries whose push wait was cut off by
|
|
1842
|
-
// the aux grace window (primary settled clean + aux timed out in grace).
|
|
1843
|
-
// Absent when no aux was cut off. These servers' diagnostics are
|
|
1844
|
-
// advisory-only and will surface on the next edit from their cache.
|
|
1845
|
-
...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
|
|
1846
|
-
},
|
|
1847
|
-
});
|
|
1848
|
-
return collected ?? [];
|
|
1849
2543
|
}
|
|
1850
2544
|
/**
|
|
1851
2545
|
* Get diagnostics for a file
|
|
@@ -2050,6 +2744,40 @@ export class LSPService {
|
|
|
2050
2744
|
hashContent(content) {
|
|
2051
2745
|
return createHash("sha256").update(content).digest("hex");
|
|
2052
2746
|
}
|
|
2747
|
+
/**
|
|
2748
|
+
* #1095: compose the content `binding` for a merged diagnostics result across
|
|
2749
|
+
* the clients that contributed to it (primary + any auxiliaries). Verifies
|
|
2750
|
+
* each contributor's stored binding against current disk lazily (memoized per
|
|
2751
|
+
* file+mtime by {@link diskBindingCache}) and composes per
|
|
2752
|
+
* {@link composeBoundToCurrentDisk}: ANY contributor mismatches disk → false;
|
|
2753
|
+
* else all "unknown" (or no contributors) → "unknown"; else true. The
|
|
2754
|
+
* surfaced version/contentHash come from the first contributor that carries
|
|
2755
|
+
* them — informational only; the load-bearing field is `boundToCurrentDisk`.
|
|
2756
|
+
* #1104: `version` and `contentHash` are tracked INDEPENDENTLY (not gated on
|
|
2757
|
+
* the same contributor carrying both) — a pull-sourced binding deliberately
|
|
2758
|
+
* has no `version` (the pull protocol has no version-echo concept) but does
|
|
2759
|
+
* carry a real `contentHash`; gating the latter on the former would silently
|
|
2760
|
+
* drop it here even though `boundToCurrentDisk` above already used it.
|
|
2761
|
+
*/
|
|
2762
|
+
mergeBinding(filePath, stored) {
|
|
2763
|
+
const verdicts = [];
|
|
2764
|
+
let version;
|
|
2765
|
+
let contentHash;
|
|
2766
|
+
for (const entry of stored) {
|
|
2767
|
+
verdicts.push(this.diskBindingCache.boundToCurrentDisk(filePath, entry ?? {}));
|
|
2768
|
+
if (version === undefined && entry?.version !== undefined) {
|
|
2769
|
+
version = entry.version;
|
|
2770
|
+
}
|
|
2771
|
+
if (contentHash === undefined && entry?.contentHash !== undefined) {
|
|
2772
|
+
contentHash = entry.contentHash;
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
return {
|
|
2776
|
+
version,
|
|
2777
|
+
contentHash,
|
|
2778
|
+
boundToCurrentDisk: composeBoundToCurrentDisk(verdicts),
|
|
2779
|
+
};
|
|
2780
|
+
}
|
|
2053
2781
|
/**
|
|
2054
2782
|
* Navigation: go to definition
|
|
2055
2783
|
*/
|
|
@@ -2148,18 +2876,18 @@ export class LSPService {
|
|
|
2148
2876
|
* advertisement in the client). If filePath is given, target that file's
|
|
2149
2877
|
* server; otherwise the first active client.
|
|
2150
2878
|
*/
|
|
2151
|
-
async executeCommand(filePath, command, args) {
|
|
2879
|
+
async executeCommand(filePath, command, args, mutationContext) {
|
|
2152
2880
|
if (filePath) {
|
|
2153
2881
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
2154
2882
|
if (!spawned) {
|
|
2155
2883
|
return { executed: false, reason: "no LSP server for file" };
|
|
2156
2884
|
}
|
|
2157
|
-
return spawned.client.executeCommand(command, args);
|
|
2885
|
+
return spawned.client.executeCommand(command, args, mutationContext);
|
|
2158
2886
|
}
|
|
2159
2887
|
const first = this.state.clients.values().next().value;
|
|
2160
2888
|
if (!first)
|
|
2161
2889
|
return { executed: false, reason: "no active LSP server" };
|
|
2162
|
-
return first.executeCommand(command, args);
|
|
2890
|
+
return first.executeCommand(command, args, mutationContext);
|
|
2163
2891
|
}
|
|
2164
2892
|
/**
|
|
2165
2893
|
* Capability snapshot for LSP operations.
|
|
@@ -2194,7 +2922,7 @@ export class LSPService {
|
|
|
2194
2922
|
if (filePath) {
|
|
2195
2923
|
const servers = getServersForFileWithConfig(filePath);
|
|
2196
2924
|
for (const server of servers) {
|
|
2197
|
-
const root = await
|
|
2925
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
2198
2926
|
if (!root)
|
|
2199
2927
|
continue;
|
|
2200
2928
|
const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
|
|
@@ -2268,6 +2996,20 @@ export class LSPService {
|
|
|
2268
2996
|
async renameFile(oldFilePath, newFilePath, options) {
|
|
2269
2997
|
const cwd = options.cwd;
|
|
2270
2998
|
const apply = options.apply ?? false;
|
|
2999
|
+
// Validate the complete resource operation before asking any server for
|
|
3000
|
+
// willRenameFiles edits. This is a read-only preflight, but it reuses the
|
|
3001
|
+
// same confinement, realpath/symlink, existence, and destination checks as
|
|
3002
|
+
// the eventual apply path, so an invalid rename cannot first mutate an
|
|
3003
|
+
// in-workspace file through returned text edits (including previews).
|
|
3004
|
+
await validateWorkspaceEdit({
|
|
3005
|
+
documentChanges: [
|
|
3006
|
+
{
|
|
3007
|
+
kind: "rename",
|
|
3008
|
+
oldUri: pathToFileURL(oldFilePath).href,
|
|
3009
|
+
newUri: pathToFileURL(newFilePath).href,
|
|
3010
|
+
},
|
|
3011
|
+
],
|
|
3012
|
+
}, cwd);
|
|
2271
3013
|
const priorityServerIds = getServersForFileWithConfig(oldFilePath).map((server) => server.id);
|
|
2272
3014
|
const activeClients = this.activeClientsForCwd(cwd, priorityServerIds);
|
|
2273
3015
|
const willRenameFailures = [];
|
|
@@ -2304,25 +3046,142 @@ export class LSPService {
|
|
|
2304
3046
|
summary,
|
|
2305
3047
|
};
|
|
2306
3048
|
}
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
3049
|
+
let applied;
|
|
3050
|
+
try {
|
|
3051
|
+
applied = await applyWorkspaceEdit(merged.edit, cwd, {
|
|
3052
|
+
mutationContext: options.mutationContext,
|
|
3053
|
+
observe: false,
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
3056
|
+
catch (err) {
|
|
3057
|
+
if (options.mutationContext) {
|
|
3058
|
+
const partial = err
|
|
3059
|
+
.appliedWorkspaceEdit;
|
|
3060
|
+
if (partial) {
|
|
3061
|
+
recordLspMutation(options.mutationContext, {
|
|
3062
|
+
results: [partial],
|
|
3063
|
+
status: "failed",
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
throw err;
|
|
3068
|
+
}
|
|
3069
|
+
const openDocuments = activeClients
|
|
3070
|
+
.filter(({ client }) => client.isDocumentOpen(oldFilePath))
|
|
3071
|
+
.map(({ serverId, client }) => ({
|
|
3072
|
+
serverId,
|
|
3073
|
+
client,
|
|
3074
|
+
oldUri: client.getDocumentUri(oldFilePath),
|
|
3075
|
+
}));
|
|
3076
|
+
const closeFailures = [];
|
|
3077
|
+
await Promise.all(openDocuments.map(async ({ serverId, client }) => {
|
|
3078
|
+
try {
|
|
3079
|
+
await client.closeDocument(oldFilePath);
|
|
3080
|
+
return undefined;
|
|
3081
|
+
}
|
|
3082
|
+
catch (err) {
|
|
3083
|
+
closeFailures.push({
|
|
3084
|
+
serverId,
|
|
3085
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3086
|
+
});
|
|
3087
|
+
return undefined;
|
|
3088
|
+
}
|
|
3089
|
+
}));
|
|
3090
|
+
if (closeFailures.length > 0) {
|
|
3091
|
+
if (options.mutationContext) {
|
|
3092
|
+
recordLspMutation(options.mutationContext, {
|
|
3093
|
+
results: [applied],
|
|
3094
|
+
status: "failed",
|
|
3095
|
+
});
|
|
3096
|
+
}
|
|
3097
|
+
// Do not rename or send didRenameFiles while any server still has the
|
|
3098
|
+
// old document open. Re-open/resync every affected client so a partial
|
|
3099
|
+
// close cannot leave an in-memory document behind the disk contents.
|
|
3100
|
+
// Reopen with the document's ACTUAL language ID (the same resolver every
|
|
3101
|
+
// genuine open uses) rather than a hardcoded "plaintext" fallback — a
|
|
3102
|
+
// wrong languageId here degrades that server's diagnostics until the
|
|
3103
|
+
// next genuine open (#1147 P3-7).
|
|
3104
|
+
const content = await fs.readFile(oldFilePath, "utf-8");
|
|
3105
|
+
const languageId = getLanguageId(oldFilePath) ?? "plaintext";
|
|
3106
|
+
await Promise.all(openDocuments.map(({ client }) => client.notify.open(oldFilePath, content, languageId, true, true)));
|
|
3107
|
+
throw new Error(`workspace/didClose failed; rename aborted: ${closeFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
|
|
3108
|
+
}
|
|
3109
|
+
let renameApplied;
|
|
3110
|
+
try {
|
|
3111
|
+
// Route the resource mutation through the same preflight confinement,
|
|
3112
|
+
// realpath and symlink policy as all other workspace edits. Do not
|
|
3113
|
+
// duplicate that security boundary with a direct mkdir/rename pair.
|
|
3114
|
+
renameApplied = await applyWorkspaceEdit({
|
|
3115
|
+
documentChanges: [
|
|
3116
|
+
{
|
|
3117
|
+
kind: "rename",
|
|
3118
|
+
oldUri: pathToFileURL(oldFilePath).href,
|
|
3119
|
+
newUri: pathToFileURL(newFilePath).href,
|
|
3120
|
+
},
|
|
3121
|
+
],
|
|
3122
|
+
}, cwd, { observe: false });
|
|
3123
|
+
}
|
|
3124
|
+
catch (err) {
|
|
3125
|
+
if (options.mutationContext) {
|
|
3126
|
+
recordLspMutation(options.mutationContext, {
|
|
3127
|
+
results: [applied],
|
|
3128
|
+
status: "failed",
|
|
3129
|
+
});
|
|
3130
|
+
}
|
|
3131
|
+
throw err;
|
|
3132
|
+
}
|
|
2310
3133
|
const relOld = path.relative(cwd, oldFilePath).replace(/\\/g, "/") ||
|
|
2311
3134
|
path.basename(oldFilePath);
|
|
2312
3135
|
const relNew = path.relative(cwd, newFilePath).replace(/\\/g, "/") ||
|
|
2313
3136
|
path.basename(newFilePath);
|
|
2314
3137
|
const renameDescription = `Renamed ${relOld} → ${relNew}`;
|
|
2315
3138
|
await Promise.all(activeClients.map(async ({ serverId, client }) => {
|
|
3139
|
+
const opened = openDocuments.find((entry) => entry.serverId === serverId);
|
|
2316
3140
|
try {
|
|
2317
|
-
|
|
3141
|
+
if (opened?.oldUri) {
|
|
3142
|
+
await client.didRenameFiles(oldFilePath, newFilePath, opened.oldUri, destinationUriPreservingSpelling(opened.oldUri, oldFilePath, newFilePath));
|
|
3143
|
+
}
|
|
3144
|
+
else {
|
|
3145
|
+
await client.didRenameFiles(oldFilePath, newFilePath);
|
|
3146
|
+
}
|
|
3147
|
+
return undefined;
|
|
2318
3148
|
}
|
|
2319
3149
|
catch (err) {
|
|
2320
3150
|
didRenameFailures.push({
|
|
2321
3151
|
serverId,
|
|
2322
3152
|
error: err instanceof Error ? err.message : String(err),
|
|
2323
3153
|
});
|
|
3154
|
+
return undefined;
|
|
2324
3155
|
}
|
|
2325
3156
|
}));
|
|
3157
|
+
const files = [...new Set([...applied.files, oldFilePath, newFilePath])];
|
|
3158
|
+
if (options.mutationContext) {
|
|
3159
|
+
recordLspMutation(options.mutationContext, {
|
|
3160
|
+
results: [
|
|
3161
|
+
{
|
|
3162
|
+
...applied,
|
|
3163
|
+
files,
|
|
3164
|
+
operationTotal: applied.operationTotal + renameApplied.operationTotal,
|
|
3165
|
+
appliedOperationTotal: applied.appliedOperationTotal + renameApplied.appliedOperationTotal,
|
|
3166
|
+
appliedOperationIndexes: [
|
|
3167
|
+
...applied.appliedOperationIndexes,
|
|
3168
|
+
...renameApplied.appliedOperationIndexes.map((index) => applied.operationTotal + index),
|
|
3169
|
+
],
|
|
3170
|
+
operationCounts: {
|
|
3171
|
+
textEdits: applied.operationCounts.textEdits + renameApplied.operationCounts.textEdits,
|
|
3172
|
+
create: applied.operationCounts.create + renameApplied.operationCounts.create,
|
|
3173
|
+
rename: applied.operationCounts.rename + renameApplied.operationCounts.rename,
|
|
3174
|
+
delete: applied.operationCounts.delete + renameApplied.operationCounts.delete,
|
|
3175
|
+
},
|
|
3176
|
+
fileDetails: [
|
|
3177
|
+
...applied.fileDetails,
|
|
3178
|
+
...renameApplied.fileDetails,
|
|
3179
|
+
],
|
|
3180
|
+
},
|
|
3181
|
+
],
|
|
3182
|
+
status: "success",
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
2326
3185
|
return {
|
|
2327
3186
|
applied: true,
|
|
2328
3187
|
serverIds: activeClients.map((entry) => entry.serverId),
|
|
@@ -2332,7 +3191,7 @@ export class LSPService {
|
|
|
2332
3191
|
inputEditCount: merged.inputEditCount,
|
|
2333
3192
|
summary,
|
|
2334
3193
|
descriptions: [...applied.descriptions, renameDescription],
|
|
2335
|
-
files
|
|
3194
|
+
files,
|
|
2336
3195
|
};
|
|
2337
3196
|
}
|
|
2338
3197
|
/**
|
|
@@ -2570,16 +3429,21 @@ export class LSPService {
|
|
|
2570
3429
|
ms: timeoutMs,
|
|
2571
3430
|
onTimeout: "undefined",
|
|
2572
3431
|
}));
|
|
3432
|
+
const coldServerIds = stillColdServerIds();
|
|
2573
3433
|
logLatency({
|
|
2574
3434
|
type: "phase",
|
|
2575
|
-
phase:
|
|
3435
|
+
phase: options.signal?.aborted
|
|
3436
|
+
? "lsp_sweep_warmup_aborted"
|
|
3437
|
+
: coldServerIds.length > 0
|
|
3438
|
+
? "lsp_sweep_warmup_failed"
|
|
3439
|
+
: "lsp_sweep_warmup_done",
|
|
2576
3440
|
filePath: representativeFile,
|
|
2577
3441
|
durationMs: Date.now() - startedAt,
|
|
2578
3442
|
metadata: {
|
|
2579
3443
|
serverIds: servers.map((s) => s.id),
|
|
2580
3444
|
timeoutMs,
|
|
2581
3445
|
attempt,
|
|
2582
|
-
coldServerIds
|
|
3446
|
+
coldServerIds,
|
|
2583
3447
|
},
|
|
2584
3448
|
});
|
|
2585
3449
|
};
|
|
@@ -2605,13 +3469,6 @@ export class LSPService {
|
|
|
2605
3469
|
}
|
|
2606
3470
|
}
|
|
2607
3471
|
if (failedServerIds.length > 0) {
|
|
2608
|
-
logLatency({
|
|
2609
|
-
type: "phase",
|
|
2610
|
-
phase: "lsp_sweep_warmup_failed",
|
|
2611
|
-
filePath: representativeFile,
|
|
2612
|
-
durationMs: 0,
|
|
2613
|
-
metadata: { failedServerIds, timeoutMs },
|
|
2614
|
-
});
|
|
2615
3472
|
// #799: record the negative cache so a LATER sweep this session
|
|
2616
3473
|
// skips straight past re-paying this warm-up (initial + retry).
|
|
2617
3474
|
// Cleared automatically the moment the server demonstrates
|
|
@@ -2673,13 +3530,31 @@ export class LSPService {
|
|
|
2673
3530
|
]);
|
|
2674
3531
|
const cachedResults = [];
|
|
2675
3532
|
const filesToTouch = [];
|
|
3533
|
+
const writeIndexByPath = new Map();
|
|
2676
3534
|
for (const filePath of files) {
|
|
3535
|
+
// Reserve order before cache lookup/touch admission. A result that settles
|
|
3536
|
+
// later is still ordered by when this scan began observing the file (#1198).
|
|
3537
|
+
writeIndexByPath.set(normalizeMapKey(filePath), options.nextWriteIndex?.());
|
|
2677
3538
|
const cached = workspaceDiagnosticsCacheCtx.lookup(filePath, workspaceSweepScopeKey);
|
|
2678
|
-
|
|
3539
|
+
// #1095 (P2-1 bug-class sweep): both sweeps share cache entries under the
|
|
3540
|
+
// same scopeKey, so this service sweep must apply the SAME content-binding
|
|
3541
|
+
// gate the tools/lsp-diagnostics.ts sibling site does — an entry whose
|
|
3542
|
+
// recorded fingerprint no longer matches disk (bytes changed WITHOUT an
|
|
3543
|
+
// mtime bump the freshness check would catch, exactly what contentHash
|
|
3544
|
+
// exists to detect) must NOT be replayed and reconciled as confirmed via
|
|
3545
|
+
// mode=full. On a mismatch, fall through to a fresh touch.
|
|
3546
|
+
if (cached && cached.binding.boundToCurrentDisk !== false) {
|
|
2679
3547
|
cachedResults.push({
|
|
2680
3548
|
filePath,
|
|
2681
3549
|
diagnostics: cached.diagnostics,
|
|
2682
3550
|
count: cached.count,
|
|
3551
|
+
// #1093: a cache hit replays an older observation — carry its
|
|
3552
|
+
// scan time so mode=full's footer reconcile stamps `touchedAt`
|
|
3553
|
+
// with when the truth was seen, not now().
|
|
3554
|
+
observedAt: cached.scannedAt,
|
|
3555
|
+
contentHash: cached.binding.contentHash,
|
|
3556
|
+
boundToCurrentDisk: cached.binding.boundToCurrentDisk,
|
|
3557
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
2683
3558
|
});
|
|
2684
3559
|
}
|
|
2685
3560
|
else {
|
|
@@ -2861,8 +3736,16 @@ export class LSPService {
|
|
|
2861
3736
|
if (attached && !attached.available) {
|
|
2862
3737
|
await this.ensureWarmForSweep(filePath, { signal });
|
|
2863
3738
|
}
|
|
2864
|
-
|
|
2865
|
-
|
|
3739
|
+
// #1179 (shape-5 structural fix): normalize both sources into the
|
|
3740
|
+
// `TouchFileResult` wrapper shape so the flag reads below come off
|
|
3741
|
+
// EXPLICIT fields, not a non-enumerable side-channel a copy would drop.
|
|
3742
|
+
// The warm-attach IPC branch resolves a plain diagnostics array with
|
|
3743
|
+
// `inconclusive` re-surfaced as an enumerable DTO field (the socket can
|
|
3744
|
+
// carry no binding, and the IPC client already rejects an inconclusive
|
|
3745
|
+
// answer, so `available` implies confirmed) — wrap it as `{ diags }`;
|
|
3746
|
+
// the incumbent branch already returns the wrapper.
|
|
3747
|
+
const touchResult = attached?.available
|
|
3748
|
+
? { diags: attached.response.diagnostics }
|
|
2866
3749
|
: await withDeadline(this.touchFile(filePath, content, {
|
|
2867
3750
|
diagnostics: "document",
|
|
2868
3751
|
collectDiagnostics: true,
|
|
@@ -2877,17 +3760,23 @@ export class LSPService {
|
|
|
2877
3760
|
// full wait budget only once across this whole sweep.
|
|
2878
3761
|
sweepIndexGate,
|
|
2879
3762
|
}), { ms: perFileMs, onTimeout: "undefined" });
|
|
3763
|
+
const diagnostics = touchResult?.diags;
|
|
2880
3764
|
// #571: prefer #570's real per-touch inconclusive signal
|
|
2881
|
-
// (`touchFile`'s
|
|
2882
|
-
//
|
|
2883
|
-
//
|
|
2884
|
-
//
|
|
2885
|
-
|
|
2886
|
-
const
|
|
2887
|
-
?.inconclusive === true;
|
|
2888
|
-
const timedOut = diagnostics === undefined || inconclusive;
|
|
3765
|
+
// (`touchFile`'s `.inconclusive` flag — set when the notify write or the
|
|
3766
|
+
// diagnostics wait itself timed out) over this sweep's own OUTER
|
|
3767
|
+
// `perFileMs` deadline, which only catches a touch that never returned at
|
|
3768
|
+
// all within budget. Either one means the result wasn't confirmed.
|
|
3769
|
+
const inconclusive = touchResult?.inconclusive === true;
|
|
3770
|
+
const timedOut = touchResult === undefined || inconclusive;
|
|
2889
3771
|
if (timedOut)
|
|
2890
3772
|
timedOutFiles += 1;
|
|
3773
|
+
// #1104 (shape 5 — AGENTS.md): the touch's content binding is an
|
|
3774
|
+
// EXPLICIT enumerable field on the wrapper now (#1179), so it survives
|
|
3775
|
+
// `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
|
|
3776
|
+
// — read it off `touchResult`, not off the derived array. A version-
|
|
3777
|
+
// less/pull-less server composed no binding, so `contentHash` stays
|
|
3778
|
+
// undefined here (honest "unknown" downstream), matching prior behavior.
|
|
3779
|
+
const rawBinding = touchResult?.binding;
|
|
2891
3780
|
// #586: honor each auxiliary profile's native inline-suppression
|
|
2892
3781
|
// comment (e.g. opengrep's `// nosemgrep`, #441) — computed from the
|
|
2893
3782
|
// raw `diagnostics` (before this drops its non-enumerable
|
|
@@ -2911,6 +3800,9 @@ export class LSPService {
|
|
|
2911
3800
|
diagnostics: filteredDiagnostics ?? [],
|
|
2912
3801
|
count: filteredDiagnostics?.length ?? 0,
|
|
2913
3802
|
timedOut,
|
|
3803
|
+
contentHash: rawBinding?.contentHash,
|
|
3804
|
+
boundToCurrentDisk: rawBinding?.boundToCurrentDisk,
|
|
3805
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
2914
3806
|
});
|
|
2915
3807
|
}
|
|
2916
3808
|
catch (err) {
|
|
@@ -2923,6 +3815,7 @@ export class LSPService {
|
|
|
2923
3815
|
// no confirmed result was obtained, so reconciliation (#571) must
|
|
2924
3816
|
// skip it the same way.
|
|
2925
3817
|
timedOut: true,
|
|
3818
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
2926
3819
|
});
|
|
2927
3820
|
}
|
|
2928
3821
|
completed += 1;
|
|
@@ -2962,7 +3855,10 @@ export class LSPService {
|
|
|
2962
3855
|
const pulled = await this.tryWorkspacePull(group.files, perFileMs);
|
|
2963
3856
|
if (pulled) {
|
|
2964
3857
|
for (const result of pulled) {
|
|
2965
|
-
results.push(
|
|
3858
|
+
results.push({
|
|
3859
|
+
...result,
|
|
3860
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
|
|
3861
|
+
});
|
|
2966
3862
|
// #671: a pull result is always confirmed (see
|
|
2967
3863
|
// `tryWorkspacePull`'s doc comment), so it's cache-eligible
|
|
2968
3864
|
// too — best-effort stat since the pull already resolved the
|
|
@@ -3031,6 +3927,8 @@ export class LSPService {
|
|
|
3031
3927
|
options.onProgress?.(completed, files.length);
|
|
3032
3928
|
return;
|
|
3033
3929
|
}
|
|
3930
|
+
if (warmup.performedWarmup)
|
|
3931
|
+
options.onServerReady?.();
|
|
3034
3932
|
}
|
|
3035
3933
|
// #608/#621: batch-open a CHUNK of this group's files before
|
|
3036
3934
|
// waiting on diagnostics for any of them individually — see
|
|
@@ -3084,7 +3982,15 @@ export class LSPService {
|
|
|
3084
3982
|
const scannedAt = scannedMtimeByFile.get(result.filePath);
|
|
3085
3983
|
if (result.error || result.timedOut || scannedAt === undefined)
|
|
3086
3984
|
continue;
|
|
3087
|
-
|
|
3985
|
+
// #1104: thread the per-result `contentHash` (from either the
|
|
3986
|
+
// `tryWorkspacePull` fast path or a per-file touch's own #1095 binding)
|
|
3987
|
+
// into the cache entry — previously this call never passed one, so
|
|
3988
|
+
// every pull-served entry read binding "unknown" forever and the #1095
|
|
3989
|
+
// P2-1 service-sweep binding gate above (`cached.binding.
|
|
3990
|
+
// boundToCurrentDisk !== false`) could never demote a pull-cached
|
|
3991
|
+
// entry. Absent `contentHash` (no binding was available) behaves
|
|
3992
|
+
// exactly as before.
|
|
3993
|
+
workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
|
|
3088
3994
|
}
|
|
3089
3995
|
workspaceDiagnosticsCacheCtx.persist();
|
|
3090
3996
|
return [...cachedResults, ...results].filter(Boolean);
|
|
@@ -3111,14 +4017,24 @@ export class LSPService {
|
|
|
3111
4017
|
return undefined;
|
|
3112
4018
|
const byPath = new Map();
|
|
3113
4019
|
for (const entry of report) {
|
|
3114
|
-
byPath.set(normalizeMapKey(entry.filePath), entry
|
|
4020
|
+
byPath.set(normalizeMapKey(entry.filePath), entry);
|
|
3115
4021
|
}
|
|
3116
4022
|
return groupFiles.map((filePath) => {
|
|
3117
|
-
const
|
|
4023
|
+
const entry = byPath.get(normalizeMapKey(filePath));
|
|
4024
|
+
const diagnostics = entry?.diagnostics ?? [];
|
|
3118
4025
|
// A pull that got here returned a real workspace/diagnostic report
|
|
3119
4026
|
// (see the `!report` guard above) — always confirmed, unlike a
|
|
3120
4027
|
// per-file touchFile default-empty on timeout.
|
|
3121
|
-
|
|
4028
|
+
// #1104: `contentHash` comes straight from the client's pull layer —
|
|
4029
|
+
// a file absent from the report (reported "clean" here) carries none,
|
|
4030
|
+
// same honest "unknown" the record site already applies for it.
|
|
4031
|
+
return {
|
|
4032
|
+
filePath,
|
|
4033
|
+
diagnostics,
|
|
4034
|
+
count: diagnostics.length,
|
|
4035
|
+
timedOut: false,
|
|
4036
|
+
contentHash: entry?.contentHash,
|
|
4037
|
+
};
|
|
3122
4038
|
});
|
|
3123
4039
|
}
|
|
3124
4040
|
catch {
|
|
@@ -3130,6 +4046,9 @@ export class LSPService {
|
|
|
3130
4046
|
*/
|
|
3131
4047
|
async getAllDiagnostics() {
|
|
3132
4048
|
const all = new Map();
|
|
4049
|
+
// #1095: per-file stored bindings across every contributing client, merged
|
|
4050
|
+
// into one `boundToCurrentDisk` verdict after the client loop.
|
|
4051
|
+
const bindingsByPath = new Map();
|
|
3133
4052
|
const now = Date.now();
|
|
3134
4053
|
for (const [_key, client] of this.state.clients) {
|
|
3135
4054
|
// Resolve existence asynchronously (was a blocking existsSync per tracked
|
|
@@ -3162,8 +4081,35 @@ export class LSPService {
|
|
|
3162
4081
|
else {
|
|
3163
4082
|
all.set(filePath, { diags: [...entry.diags], ts: entry.ts });
|
|
3164
4083
|
}
|
|
4084
|
+
const list = bindingsByPath.get(filePath) ?? [];
|
|
4085
|
+
list.push(entry.binding);
|
|
4086
|
+
bindingsByPath.set(filePath, list);
|
|
3165
4087
|
}
|
|
3166
4088
|
}
|
|
4089
|
+
// #1095 (P2-2): expose the composed content binding per file LAZILY — the
|
|
4090
|
+
// disk stat+hash verify only runs when a consumer actually reads `.binding`.
|
|
4091
|
+
// The current caller (the cascade, once per edit turn) does NOT read it yet
|
|
4092
|
+
// (binding adoption in cascade/integration.ts is deferred to the second PR),
|
|
4093
|
+
// so this leaves ZERO eager disk cost while still surfacing the field for the
|
|
4094
|
+
// consumer that arrives next. Non-enumerable so incidental spread/serialize
|
|
4095
|
+
// (e.g. logging) can't trigger a stat storm; memoized per entry so repeated
|
|
4096
|
+
// reads verify once.
|
|
4097
|
+
// #1179: DELIBERATELY NOT migrated to an enumerable wrapper field (unlike the
|
|
4098
|
+
// `touchFile` flags). Enumerable would make an incidental `{...entry}`/serialize
|
|
4099
|
+
// eagerly fire this getter and stat every file — the exact stat storm the
|
|
4100
|
+
// laziness above exists to prevent. It therefore stays on the read-off-original
|
|
4101
|
+
// carriage contract (see `DiagnosticBinding`): every consumer reads `.binding`
|
|
4102
|
+
// off the ORIGINAL Map entry (`clients/dispatch/integration.ts` reads it off
|
|
4103
|
+
// `entry`, never a copy), and the cascade's TTL gate reads it only when fresh.
|
|
4104
|
+
for (const [filePath, entry] of all) {
|
|
4105
|
+
const stored = bindingsByPath.get(filePath) ?? [];
|
|
4106
|
+
let memo;
|
|
4107
|
+
Object.defineProperty(entry, "binding", {
|
|
4108
|
+
enumerable: false,
|
|
4109
|
+
configurable: true,
|
|
4110
|
+
get: () => (memo ??= this.mergeBinding(filePath, stored)),
|
|
4111
|
+
});
|
|
4112
|
+
}
|
|
3167
4113
|
return all;
|
|
3168
4114
|
}
|
|
3169
4115
|
/**
|
|
@@ -3194,9 +4140,13 @@ export class LSPService {
|
|
|
3194
4140
|
* Shutdown all LSP clients
|
|
3195
4141
|
*/
|
|
3196
4142
|
async shutdown(options = {}) {
|
|
4143
|
+
const resetStartedAt = Date.now();
|
|
3197
4144
|
if (this.checkDestroyed())
|
|
3198
4145
|
return;
|
|
3199
4146
|
this.isDestroyed = true;
|
|
4147
|
+
for (const key of this.typeScriptIdleTimers.keys()) {
|
|
4148
|
+
this.clearTypeScriptIdleTimer(key);
|
|
4149
|
+
}
|
|
3200
4150
|
// Belt-and-braces: wait for any in-flight spawns so that Guard 1/2 in
|
|
3201
4151
|
// spawnClient can observe isDestroyed and clean up. Skip on the
|
|
3202
4152
|
// process-exiting path — the event loop is closing and we must not block.
|
|
@@ -3217,11 +4167,19 @@ export class LSPService {
|
|
|
3217
4167
|
// snapshot of what was released by this reset (post-teardown the count
|
|
3218
4168
|
// would always be zero, which is useless for root-cause analysis).
|
|
3219
4169
|
const aliveClients = this.getAliveClientCount();
|
|
4170
|
+
// Start every client teardown before awaiting any of them. A non-fast
|
|
4171
|
+
// process-tree kill can spend its grace period per client, so awaiting in
|
|
4172
|
+
// map order makes the reset tail O(clientCount * grace) instead of the
|
|
4173
|
+
// maximum individual teardown. allSettled preserves the per-client
|
|
4174
|
+
// best-effort contract: one failure must not prevent other clients from
|
|
4175
|
+
// finishing, and the caller still waits for every teardown to settle.
|
|
4176
|
+
await Promise.allSettled(Array.from(this.state.clients.values(), (client) => Promise.resolve().then(() => client.shutdown(options))));
|
|
3220
4177
|
logLatency({
|
|
3221
4178
|
type: "phase",
|
|
3222
4179
|
phase: "lsp_service_reset",
|
|
3223
4180
|
filePath: "",
|
|
3224
|
-
|
|
4181
|
+
startedAt: new Date(resetStartedAt).toISOString(),
|
|
4182
|
+
durationMs: Date.now() - resetStartedAt,
|
|
3225
4183
|
metadata: {
|
|
3226
4184
|
reason: options.reason ?? null,
|
|
3227
4185
|
aliveClients,
|
|
@@ -3229,13 +4187,6 @@ export class LSPService {
|
|
|
3229
4187
|
processExiting: !!options.processExiting,
|
|
3230
4188
|
},
|
|
3231
4189
|
});
|
|
3232
|
-
// Start every client teardown before awaiting any of them. A non-fast
|
|
3233
|
-
// process-tree kill can spend its grace period per client, so awaiting in
|
|
3234
|
-
// map order makes the reset tail O(clientCount * grace) instead of the
|
|
3235
|
-
// maximum individual teardown. allSettled preserves the per-client
|
|
3236
|
-
// best-effort contract: one failure must not prevent other clients from
|
|
3237
|
-
// finishing, and the caller still waits for every teardown to settle.
|
|
3238
|
-
await Promise.allSettled(Array.from(this.state.clients.values(), (client) => Promise.resolve().then(() => client.shutdown(options))));
|
|
3239
4190
|
this.state.clients.clear();
|
|
3240
4191
|
this.state.broken.clear();
|
|
3241
4192
|
this.workspaceProbeLogged.clear();
|
|
@@ -3247,7 +4198,93 @@ export class LSPService {
|
|
|
3247
4198
|
getStatus() {
|
|
3248
4199
|
return Array.from(this.state.clients.entries()).map(([key, client]) => {
|
|
3249
4200
|
const [serverId, root] = key.split(":");
|
|
3250
|
-
return {
|
|
4201
|
+
return {
|
|
4202
|
+
serverId,
|
|
4203
|
+
root,
|
|
4204
|
+
connected: client.isAlive(),
|
|
4205
|
+
pullFailureHistory: (client.getPullFailureHistory?.() ?? []).map((entry) => ({
|
|
4206
|
+
...entry,
|
|
4207
|
+
message: entry.message.slice(0, 200),
|
|
4208
|
+
})),
|
|
4209
|
+
};
|
|
4210
|
+
});
|
|
4211
|
+
}
|
|
4212
|
+
/**
|
|
4213
|
+
* #1142 windowed-rate breaker (composes with the {@link runtimeExitCounts}
|
|
4214
|
+
* fast path — see that field and {@link RUNTIME_EXIT_WINDOW_MS}).
|
|
4215
|
+
*
|
|
4216
|
+
* Records one non-intentional death and returns true iff this death pushes
|
|
4217
|
+
* the rolling window to {@link RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths, i.e.
|
|
4218
|
+
* the breaker should give up now. The caller has ALREADY excluded intentional
|
|
4219
|
+
* teardowns (shutdown()-driven restarts, #743 eviction, session resets,
|
|
4220
|
+
* generation handoffs) — only genuine crash-respawns reach here.
|
|
4221
|
+
*
|
|
4222
|
+
* Sleep-gap / long-healthy-run guard: a death whose measured lifetime exceeds
|
|
4223
|
+
* {@link RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS} (or is unknown) is NOT
|
|
4224
|
+
* recorded — a lifetime spanning a Modern-Standby suspend, or a genuinely
|
|
4225
|
+
* long run that crashed once, is not churn. See that constant for the
|
|
4226
|
+
* per-KEY structural defense this backs up.
|
|
4227
|
+
*
|
|
4228
|
+
* Bounded on both axes (defect shape 9): prune entries outside the window,
|
|
4229
|
+
* then hard-cap the retained array at the trip count (drop-oldest — a rate
|
|
4230
|
+
* trip needs only the in-window count, so earliest-evidence order is
|
|
4231
|
+
* irrelevant here).
|
|
4232
|
+
*/
|
|
4233
|
+
recordRuntimeExitWindow(key, exitedAt, uptimeMs) {
|
|
4234
|
+
if (exitedAt == null ||
|
|
4235
|
+
uptimeMs == null ||
|
|
4236
|
+
uptimeMs > RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS) {
|
|
4237
|
+
return false;
|
|
4238
|
+
}
|
|
4239
|
+
const deaths = this.runtimeExitWindow.get(key) ?? [];
|
|
4240
|
+
deaths.push(exitedAt);
|
|
4241
|
+
// Roll the window around the most recent death. `exitedAt` is the client's
|
|
4242
|
+
// own recorded death time (#1127), which can arrive out of DETECTION order
|
|
4243
|
+
// — a death detected hours later still carries its true, older timestamp —
|
|
4244
|
+
// so anchor on the max, not on push order.
|
|
4245
|
+
const windowRef = Math.max(...deaths);
|
|
4246
|
+
const windowStart = windowRef - RUNTIME_EXIT_WINDOW_MS;
|
|
4247
|
+
let pruned = deaths.filter((t) => t >= windowStart);
|
|
4248
|
+
if (pruned.length > RUNTIME_EXIT_WINDOW_TRIP_COUNT) {
|
|
4249
|
+
pruned = pruned.slice(pruned.length - RUNTIME_EXIT_WINDOW_TRIP_COUNT);
|
|
4250
|
+
}
|
|
4251
|
+
this.runtimeExitWindow.set(key, pruned);
|
|
4252
|
+
return pruned.length >= RUNTIME_EXIT_WINDOW_TRIP_COUNT;
|
|
4253
|
+
}
|
|
4254
|
+
/**
|
|
4255
|
+
* Read-only circuit-breaker status, including server/root pairs that have no
|
|
4256
|
+
* live client and would therefore be absent from getStatus().
|
|
4257
|
+
*
|
|
4258
|
+
* #1127: `failureCounts` (spawn/init failures) and `runtimeExitCounts`
|
|
4259
|
+
* (early post-init runtime exits) are two INDEPENDENT streams feeding one
|
|
4260
|
+
* circuit — either can trip its own cooldown/permanent-disable on its own
|
|
4261
|
+
* threshold, without the other. `failures` below is their SUM, purely for
|
|
4262
|
+
* display: it is the total churn behind whatever cooldown/permanent state
|
|
4263
|
+
* is showing, not a claim that both streams are at that count.
|
|
4264
|
+
*/
|
|
4265
|
+
getBrokenStatus() {
|
|
4266
|
+
const keys = new Set([
|
|
4267
|
+
...this.state.broken.keys(),
|
|
4268
|
+
...this.permanentlyBroken,
|
|
4269
|
+
]);
|
|
4270
|
+
return [...keys].map((key) => {
|
|
4271
|
+
const separator = key.indexOf(":");
|
|
4272
|
+
return {
|
|
4273
|
+
serverId: separator >= 0 ? key.slice(0, separator) : key,
|
|
4274
|
+
root: separator >= 0 ? key.slice(separator + 1) : "",
|
|
4275
|
+
// #1127/#1142: spawn/init failures, consecutive early post-init
|
|
4276
|
+
// exits, and the windowed-rate stream all feed the same breaker
|
|
4277
|
+
// (see `runtimeExitCounts`/`runtimeExitWindow` docs). Sum spawn/init
|
|
4278
|
+
// failures with the LARGER of the two runtime-exit streams — a
|
|
4279
|
+
// single <60s death feeds BOTH runtimeExitCounts and the window, so
|
|
4280
|
+
// `max` avoids double-counting them, while still surfacing a
|
|
4281
|
+
// windowed-only trip (whose runtimeExitCounts may be 0) instead of
|
|
4282
|
+
// misreporting `failures: 0` behind a permanent-disable.
|
|
4283
|
+
failures: (this.failureCounts.get(key) ?? 0) +
|
|
4284
|
+
Math.max(this.runtimeExitCounts.get(key) ?? 0, this.runtimeExitWindow.get(key)?.length ?? 0),
|
|
4285
|
+
permanentlyBroken: this.permanentlyBroken.has(key),
|
|
4286
|
+
cooldownUntil: this.state.broken.get(key) ?? 0,
|
|
4287
|
+
};
|
|
3251
4288
|
});
|
|
3252
4289
|
}
|
|
3253
4290
|
/**
|