pi-lens 4.1.2 → 4.1.4
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 +1308 -0
- package/README.md +3 -0
- package/config/biome/core.jsonc +11 -2
- package/config/dependency-cruiser-eager-allowlist.json +91 -0
- package/dist/clients/actionable-warnings-logger.js +18 -3
- package/dist/clients/actionable-warnings.js +927 -80
- package/dist/clients/agent-behavior-client.js +13 -4
- package/dist/clients/ast-grep-client.js +136 -9
- package/dist/clients/ast-grep-rule-manager.js +60 -5
- package/dist/clients/ast-grep-tool-logger.js +2 -2
- package/dist/clients/availability-probe-flight.js +12 -0
- package/dist/clients/bash-file-access.js +1 -3
- package/dist/clients/biome-client.js +16 -2
- package/dist/clients/blocker-freshness.js +34 -0
- package/dist/clients/bootstrap.js +509 -73
- package/dist/clients/bounded-cache.js +152 -12
- package/dist/clients/bounded-telemetry.js +115 -23
- package/dist/clients/build-identity.js +95 -0
- package/dist/clients/bundled-resource-health.js +113 -0
- package/dist/clients/bus-events-logger.js +2 -2
- package/dist/clients/bus-publish.js +3 -0
- package/dist/clients/cache/rule-cache.js +33 -5
- package/dist/clients/cache-manager.js +105 -6
- package/dist/clients/cache-observability.js +46 -19
- package/dist/clients/cargo-manifest.js +422 -0
- package/dist/clients/cascade-logger.js +17 -3
- package/dist/clients/code-quality-warnings.js +13 -3
- package/dist/clients/complexity-client.js +16 -5
- package/dist/clients/config-core/deny.js +221 -0
- package/dist/clients/config-core/index.js +47 -0
- package/dist/clients/config-core/merge.js +357 -0
- package/dist/clients/config-core/normalize.js +340 -0
- package/dist/clients/config-core/process-spec.js +248 -0
- package/dist/clients/config-core/provenance.js +171 -0
- package/dist/clients/config-core/records.js +218 -0
- package/dist/clients/config-core/resolve.js +125 -0
- package/dist/clients/config-core/safe-object.js +78 -0
- package/dist/clients/config-core/schema.js +167 -0
- package/dist/clients/config-diagnostic-codes.js +281 -0
- package/dist/clients/config-locations.js +160 -0
- package/dist/clients/config-resolve.js +789 -0
- package/dist/clients/config-schema.js +197 -0
- package/dist/clients/config-warn.js +407 -0
- package/dist/clients/dead-code-client.js +33 -25
- package/dist/clients/dead-code-logger.js +2 -2
- package/dist/clients/deadline-utils.js +178 -0
- package/dist/clients/debug-handles.js +2 -2
- package/dist/clients/debug-heap.js +3 -3
- package/dist/clients/deferred-lsp-work.js +106 -0
- package/dist/clients/degradation-ledger.js +166 -8
- package/dist/clients/demoted-finding-render.js +14 -0
- package/dist/clients/dependency-checker.js +28 -22
- package/dist/clients/deps/minimatch.js +1 -1
- package/dist/clients/diagnostic-line-freshness.js +12 -9
- package/dist/clients/diagnostic-logger.js +14 -4
- package/dist/clients/dispatch/collect-later-tier.js +39 -0
- package/dist/clients/dispatch/dispatcher.js +304 -19
- package/dist/clients/dispatch/fact-store.js +328 -9
- package/dist/clients/dispatch/integration.js +214 -107
- package/dist/clients/dispatch/pending-runner-findings.js +87 -0
- package/dist/clients/dispatch/plan.js +21 -5
- package/dist/clients/dispatch/runners/actionlint.js +0 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
- package/dist/clients/dispatch/runners/biome-check.js +0 -1
- package/dist/clients/dispatch/runners/cpp-check.js +0 -1
- package/dist/clients/dispatch/runners/credo.js +0 -1
- package/dist/clients/dispatch/runners/cue-vet.js +0 -1
- package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
- package/dist/clients/dispatch/runners/detekt.js +0 -1
- package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
- package/dist/clients/dispatch/runners/elixir-check.js +0 -1
- package/dist/clients/dispatch/runners/eslint.js +5 -2
- package/dist/clients/dispatch/runners/fact-rules.js +0 -1
- package/dist/clients/dispatch/runners/fish-indent.js +0 -1
- package/dist/clients/dispatch/runners/gleam-check.js +0 -1
- package/dist/clients/dispatch/runners/go-vet.js +1 -3
- package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
- package/dist/clients/dispatch/runners/hadolint.js +0 -1
- package/dist/clients/dispatch/runners/helm-lint.js +16 -4
- package/dist/clients/dispatch/runners/helm-render.js +54 -10
- package/dist/clients/dispatch/runners/htmlhint.js +0 -1
- package/dist/clients/dispatch/runners/index.js +1 -3
- package/dist/clients/dispatch/runners/javac.js +0 -1
- package/dist/clients/dispatch/runners/ktlint.js +0 -1
- package/dist/clients/dispatch/runners/lsp.js +37 -2
- package/dist/clients/dispatch/runners/markdownlint.js +4 -2
- package/dist/clients/dispatch/runners/mypy.js +0 -1
- package/dist/clients/dispatch/runners/oxlint.js +24 -22
- package/dist/clients/dispatch/runners/php-lint.js +0 -1
- package/dist/clients/dispatch/runners/phpstan.js +0 -1
- package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
- package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
- package/dist/clients/dispatch/runners/pyright.js +7 -4
- package/dist/clients/dispatch/runners/rubocop.js +0 -1
- package/dist/clients/dispatch/runners/ruff.js +0 -1
- package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
- package/dist/clients/dispatch/runners/shellcheck.js +0 -1
- package/dist/clients/dispatch/runners/shfmt.js +0 -1
- package/dist/clients/dispatch/runners/spellcheck.js +0 -1
- package/dist/clients/dispatch/runners/spotbugs.js +0 -1
- package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
- package/dist/clients/dispatch/runners/stylelint.js +0 -1
- package/dist/clients/dispatch/runners/swiftlint.js +0 -1
- package/dist/clients/dispatch/runners/taplo.js +0 -1
- package/dist/clients/dispatch/runners/terragrunt.js +0 -1
- package/dist/clients/dispatch/runners/tflint.js +0 -1
- package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
- package/dist/clients/dispatch/runners/trivy-config.js +0 -1
- package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
- package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
- package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
- package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
- package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
- package/dist/clients/dispatch/runners/vale.js +0 -1
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
- package/dist/clients/dispatch/runners/yamllint.js +0 -1
- package/dist/clients/dispatch/runners/zig-check.js +0 -1
- package/dist/clients/disposition-logger.js +3 -3
- package/dist/clients/effective-config.js +403 -0
- package/dist/clients/error-class.js +23 -0
- package/dist/clients/event-loop-hold.js +274 -0
- package/dist/clients/event-loop-monitor.js +66 -1
- package/dist/clients/extension-log.js +2 -2
- package/dist/clients/feature-hints.js +2 -1
- package/dist/clients/file-time.js +6 -1
- package/dist/clients/file-utils.js +266 -13
- package/dist/clients/finding-delivery-gate.js +55 -13
- package/dist/clients/format-service.js +6 -1
- package/dist/clients/formatters.js +303 -104
- package/dist/clients/freshness-cadence.js +17 -0
- package/dist/clients/generated-artifacts.js +114 -19
- package/dist/clients/generation-guard.js +4 -9
- package/dist/clients/git-guard.js +198 -48
- package/dist/clients/git-tracked-ignore.js +32 -1
- package/dist/clients/go-client.js +39 -0
- package/dist/clients/govulncheck-client.js +5 -1
- package/dist/clients/gradle-ktfmt-style.js +252 -0
- package/dist/clients/gzip-stage-write.js +7 -0
- package/dist/clients/hashline-anchor.js +424 -0
- package/dist/clients/hook-budgets.js +76 -0
- package/dist/clients/host-edit-normalize.js +5 -2
- package/dist/clients/host-ports.js +1 -1
- package/dist/clients/installer/index.js +200 -84
- package/dist/clients/installer/managed-tool-refresh.js +3 -1
- package/dist/clients/instance-reaper.js +108 -243
- package/dist/clients/instance-registry-lock.js +145 -0
- package/dist/clients/instance-registry.js +435 -122
- package/dist/clients/jscpd-client.js +6 -1
- package/dist/clients/language-profile.js +4 -2
- package/dist/clients/language-registry.js +595 -0
- package/dist/clients/latency-logger.js +179 -3
- package/dist/clients/ledger-bounds.js +13 -0
- package/dist/clients/lens-config.js +141 -30
- package/dist/clients/lens-engine.js +30 -37
- package/dist/clients/lens-flag-registry.js +67 -6
- package/dist/clients/lens-map.js +3 -1
- package/dist/clients/log-cleanup.js +2 -2
- package/dist/clients/lsp/aggregation.js +3 -1
- package/dist/clients/lsp/cascade-tier.js +4 -0
- package/dist/clients/lsp/client.js +672 -49
- package/dist/clients/lsp/config.js +504 -156
- package/dist/clients/lsp/diagnostic-binding.js +10 -5
- package/dist/clients/lsp/edits.js +15 -2
- package/dist/clients/lsp/index.js +1179 -247
- package/dist/clients/lsp/jvm-runtime.js +5 -0
- package/dist/clients/lsp/language.js +18 -169
- package/dist/clients/lsp/launch.js +38 -9
- package/dist/clients/lsp/pending-aux-coverage.js +113 -12
- package/dist/clients/lsp/server.js +426 -160
- package/dist/clients/lsp/session-roots.js +192 -0
- package/dist/clients/lsp/spawn-history.js +2 -7
- package/dist/clients/lsp/tsserver-sync.js +9 -2
- package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
- package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
- package/dist/clients/lsp-mutation.js +179 -23
- package/dist/clients/map-with-concurrency.js +36 -0
- package/dist/clients/mcp/analyze.js +45 -10
- package/dist/clients/mcp/session.js +19 -17
- package/dist/clients/memory-sampler.js +28 -1
- package/dist/clients/message-end-attribution.js +30 -0
- package/dist/clients/metrics-history.js +21 -4
- package/dist/clients/middle-man-analysis.js +2 -4
- package/dist/clients/module-report.js +20 -45
- package/dist/clients/mutating-tool.js +651 -0
- package/dist/clients/mutation-attribution.js +368 -0
- package/dist/clients/mutation-bridge.js +240 -0
- package/dist/clients/ndjson-logger.js +247 -20
- package/dist/clients/observed-mutation-sources.js +101 -0
- package/dist/clients/observed-mutation.js +1215 -0
- package/dist/clients/opaque-mutation-scan.js +300 -40
- package/dist/clients/opengrep-config.js +4 -0
- package/dist/clients/package-manager.js +201 -44
- package/dist/clients/partial-edit-apply.js +359 -80
- package/dist/clients/path-attribution-telemetry.js +23 -7
- package/dist/clients/path-keyed-map.js +35 -3
- package/dist/clients/path-utils.js +460 -13
- package/dist/clients/persist-debounce.js +8 -1
- package/dist/clients/php-cs-fixer-config.js +114 -0
- package/dist/clients/pipeline.js +77 -24
- package/dist/clients/probe-home-state.js +228 -0
- package/dist/clients/process-bridge.js +66 -0
- package/dist/clients/process-singletons.js +210 -0
- package/dist/clients/process-snapshot.js +68 -0
- package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
- package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
- package/dist/clients/project-diagnostics/scanner.js +61 -24
- package/dist/clients/project-lens-config.js +393 -122
- package/dist/clients/project-snapshot.js +85 -35
- package/dist/clients/python-environment.js +217 -0
- package/dist/clients/python-provenance.js +639 -0
- package/dist/clients/quiet-window.js +6 -1
- package/dist/clients/read-bridge.js +13 -21
- package/dist/clients/read-expansion.js +9 -46
- package/dist/clients/read-guard-logger.js +39 -6
- package/dist/clients/read-guard-tool-lines.js +364 -212
- package/dist/clients/read-guard.js +95 -7
- package/dist/clients/recent-touches.js +17 -5
- package/dist/clients/resource-sampler.js +195 -96
- package/dist/clients/review-graph/builder.js +752 -132
- package/dist/clients/review-graph/service.js +55 -3
- package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
- package/dist/clients/review-graph/tsconfig-paths.js +5 -1
- package/dist/clients/review-graph/workspace-modules.js +10 -74
- package/dist/clients/review-graph-logger.js +30 -4
- package/dist/clients/runtime-agent-end.js +18 -2
- package/dist/clients/runtime-context.js +49 -16
- package/dist/clients/runtime-coordinator.js +93 -6
- package/dist/clients/runtime-session.js +169 -34
- package/dist/clients/runtime-tool-call.js +183 -35
- package/dist/clients/runtime-tool-result.js +894 -241
- package/dist/clients/runtime-turn.js +1264 -74
- package/dist/clients/rust-client.js +31 -0
- package/dist/clients/safe-spawn.js +191 -28
- package/dist/clients/sanitize.js +7 -1
- package/dist/clients/scratch-tree-policy.js +1 -3
- package/dist/clients/security-scan-client.js +51 -17
- package/dist/clients/session-lifecycle.js +238 -42
- package/dist/clients/session-start-observability.js +79 -0
- package/dist/clients/sessionstart-logger.js +12 -3
- package/dist/clients/sg-runner.js +27 -15
- package/dist/clients/sgconfig.js +6 -1
- package/dist/clients/shared-checkout-guard.js +338 -0
- package/dist/clients/skills-resolver.js +105 -0
- package/dist/clients/smells-rollup.js +2 -2
- package/dist/clients/spawn-output-cap.js +37 -0
- package/dist/clients/spawn-timeout-cooldown.js +15 -1
- package/dist/clients/startup-scan.js +6 -7
- package/dist/clients/startup-timing.js +69 -4
- package/dist/clients/string-utils.js +33 -0
- package/dist/clients/subagent-mode.js +17 -4
- package/dist/clients/test-runner-client.js +586 -118
- package/dist/clients/test-runner-delivery.js +239 -0
- package/dist/clients/tool-definition.js +41 -1
- package/dist/clients/tool-policy.js +92 -22
- package/dist/clients/tree-sitter-cache.js +40 -40
- package/dist/clients/tree-sitter-client.js +97 -53
- package/dist/clients/tree-sitter-logger.js +19 -3
- package/dist/clients/tree-sitter-query-loader.js +81 -1
- package/dist/clients/tree-sitter-shared.js +51 -46
- package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
- package/dist/clients/typos-config.js +5 -0
- package/dist/clients/user-notify.js +6 -2
- package/dist/clients/warm-attach.js +8 -10
- package/dist/clients/widget-state.js +191 -26
- package/dist/clients/word-index-logger.js +15 -3
- package/dist/clients/word-index-store.js +463 -0
- package/dist/clients/word-index.js +815 -125
- package/dist/clients/workspace-topology.js +25 -1
- package/dist/clients/zizmor-config.js +29 -6
- package/dist/index.js +68577 -53950
- package/dist/mcp/analyze-cli.js +3 -1
- package/dist/mcp/build-staleness.js +8 -7
- package/dist/mcp/cli.js +3 -1
- package/dist/mcp/server.js +133 -6
- package/dist/scripts/lib/process-scan.mjs +583 -0
- package/dist/scripts/lib/skills-predicate.mjs +129 -0
- package/dist/tools/effective-config.js +89 -0
- package/dist/tools/lens-diagnostics.js +99 -24
- package/dist/tools/lsp-diagnostics.js +81 -85
- package/dist/tools/lsp-navigation.js +40 -17
- package/dist/tools/shared.js +0 -1
- package/docs/agent-guide.md +68 -3
- package/docs/ast-grep_rules_catalog.md +41 -10
- package/docs/astplayground.md +26 -17
- package/docs/configuration.md +222 -0
- package/docs/dependencies.md +3 -3
- package/docs/features.md +44 -5
- package/docs/language-coverage.md +2 -2
- package/docs/pi-lens-fixer.md +25 -0
- package/docs/pi-lens-investigator.md +25 -0
- package/docs/pi-lens-reviewer.md +27 -0
- package/docs/pi-lens-subagent.md +38 -0
- package/docs/pi-lens-warden.md +55 -0
- package/docs/public-api-stability.md +359 -0
- package/docs/release-qa-baseline.md +182 -0
- package/docs/subagent-compat.md +110 -30
- package/docs/tree-sitter_rules_catalog.md +2 -2
- package/package.json +17 -21
- package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
- package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
- package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
- package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
- package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
- package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
- package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
- package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
- package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
- package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
- package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
- package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
- package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
- package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
- package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
- package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
- package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
- package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
- package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
- package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
- package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
- package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
- package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
- package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
- package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
- package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
- package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
- package/rules/ast-grep-rules/rules/no-important.yml +21 -0
- package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
- package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
- package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
- package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
- package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
- package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
- package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
- package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
- package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
- package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
- package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
- package/scripts/analyze-pi-lens-logs.mjs +192 -1
- package/scripts/install-selftest.mjs +58 -2
- package/scripts/lib/skills-predicate.mjs +129 -0
- package/scripts/rpc-load-check.mjs +3 -1
|
@@ -12,27 +12,36 @@ import * as nodeFs from "node:fs";
|
|
|
12
12
|
import fs from "node:fs/promises";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { pathToFileURL, URL } from "node:url";
|
|
15
|
+
import { BoundedFifoMap } from "../bounded-cache.js";
|
|
15
16
|
import { getProjectIgnoreMatcher, isExcludedDirName } from "../file-utils.js";
|
|
16
17
|
import { recordLsp } from "../widget-state.js";
|
|
17
18
|
import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
|
|
18
19
|
import { detectFileRole } from "../file-role.js";
|
|
19
20
|
import { emitBounded } from "../bounded-telemetry.js";
|
|
20
|
-
import { logLatency } from "../latency-logger.js";
|
|
21
|
+
import { logLatency, phaseFinished, phaseStarted } from "../latency-logger.js";
|
|
21
22
|
import { logSessionStart } from "../sessionstart-logger.js";
|
|
22
23
|
import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
|
|
23
24
|
import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
|
|
24
25
|
import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
|
|
25
|
-
import {
|
|
26
|
+
import { sampleProcessTreeCpuPercent } from "../resource-sampler.js";
|
|
27
|
+
import { bounded, withDeadline, withTimeout } from "../deadline-utils.js";
|
|
28
|
+
import { getAmbientAbortSignal } from "../safe-spawn.js";
|
|
29
|
+
import { abortDeferredLspWork } from "../deferred-lsp-work.js";
|
|
26
30
|
import { acquireWorkspaceSweepHold, clearWorkspaceSweepHoldForSessionStart, } from "./workspace-sweep-hold.js";
|
|
27
31
|
import { DocumentDriftTracker, fingerprintDocumentContent, } from "./document-drift.js";
|
|
28
|
-
import { markPendingAuxiliaryCoverage } from "./pending-aux-coverage.js";
|
|
32
|
+
import { markPendingAuxiliaryCoverage, napiFallbackCoveredSince, } from "./pending-aux-coverage.js";
|
|
29
33
|
import { isAtOrAboveHomeDir, isWindowsPath, normalizeMapKey, uriToPath, } from "../path-utils.js";
|
|
30
34
|
import { recordLspMutation } from "../lsp-mutation.js";
|
|
31
35
|
import { createLSPClient } from "./client.js";
|
|
32
36
|
import { auxiliaryCoverageGap, bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, hashDiagnosticContent, resolveTouchVerdict, touchCoverageGap, } from "./diagnostic-binding.js";
|
|
33
37
|
import { getServersForFileWithConfig, getServerInitOverride, } from "./config.js";
|
|
38
|
+
// #2052: deliberately NOT taken from `config.js`. This module's import surface
|
|
39
|
+
// from `config.js` is mirrored by explicit `vi.mock` factories in ~58 test
|
|
40
|
+
// files, so every new symbol taken from there breaks all of them.
|
|
41
|
+
import { getSessionRootsForTelemetry, isOutsideAllSessionRoots, } from "./session-roots.js";
|
|
42
|
+
import { getProcessSingleton } from "../process-singletons.js";
|
|
34
43
|
import { getLanguageId } from "./language.js";
|
|
35
|
-
import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, } from "./server.js";
|
|
44
|
+
import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, resetLspLaunchAvailabilityGeneration, } from "./server.js";
|
|
36
45
|
import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
|
|
37
46
|
const WORKSPACE_ATTRIBUTION_CLIENT_CAP = 16;
|
|
38
47
|
import { raceToCompletion } from "./aggregation.js";
|
|
@@ -301,6 +310,19 @@ function readEnvAuxGraceMs() {
|
|
|
301
310
|
return undefined;
|
|
302
311
|
return parsed;
|
|
303
312
|
}
|
|
313
|
+
const DEFAULT_AUX_GRACE_CEILING_MS = 2000;
|
|
314
|
+
const MAX_ADAPTIVE_AUX_GRACE_CEILING_MS = 8000;
|
|
315
|
+
const ADAPTIVE_AUX_GRACE_MARGIN_MS = 500;
|
|
316
|
+
export function auxWaitBudgetMs(serverId, isCold, configuredCeilingMs, declaredWaitMs) {
|
|
317
|
+
if (configuredCeilingMs !== undefined || !isCold) {
|
|
318
|
+
return Math.min(declaredWaitMs, configuredCeilingMs ?? DEFAULT_AUX_GRACE_CEILING_MS);
|
|
319
|
+
}
|
|
320
|
+
const observedSpawnMs = getSuccessfulLspSpawnDurationMs(serverId);
|
|
321
|
+
if (observedSpawnMs === undefined || observedSpawnMs <= 0) {
|
|
322
|
+
return Math.min(declaredWaitMs, DEFAULT_AUX_GRACE_CEILING_MS);
|
|
323
|
+
}
|
|
324
|
+
return Math.min(MAX_ADAPTIVE_AUX_GRACE_CEILING_MS, Math.max(declaredWaitMs, observedSpawnMs + ADAPTIVE_AUX_GRACE_MARGIN_MS));
|
|
325
|
+
}
|
|
304
326
|
const DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_THRESHOLD_MS ?? "250", 10) || 250);
|
|
305
327
|
const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_SETTLE_MS ?? "400", 10) || 400);
|
|
306
328
|
// Once the fastest client has diagnostics, remaining clients get this window before
|
|
@@ -450,6 +472,19 @@ function notifyWriteBudgetMs() {
|
|
|
450
472
|
const raw = Number(process.env.PI_LENS_LSP_NOTIFY_BUDGET_MS);
|
|
451
473
|
return Number.isFinite(raw) && raw > 0 ? raw : 2000;
|
|
452
474
|
}
|
|
475
|
+
// #2239: the SAME effective per-server wait floor `getClientForFile` uses to
|
|
476
|
+
// size its own acquisition race — the caller's declared budget, raised to
|
|
477
|
+
// whatever the file's primary server(s) configure via `clientWaitTimeoutMs`
|
|
478
|
+
// (Ruby 30s, and the Bash/JSON/Vue/Svelte/Prisma overrides #2233 added). A
|
|
479
|
+
// cold primary spawn is allowed to run this long, so anything downstream that
|
|
480
|
+
// charges itself against `maxWaitMs` alone — rather than this floor — sees an
|
|
481
|
+
// already-elapsed time it never budgeted for and clamps to zero.
|
|
482
|
+
function primaryServerWaitFloorMs(filePath, maxWaitMs) {
|
|
483
|
+
const serverWaitOverrideMs = getServersForFileWithConfig(filePath)
|
|
484
|
+
.filter((s) => s.role !== "auxiliary")
|
|
485
|
+
.reduce((max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0), 0);
|
|
486
|
+
return Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
|
|
487
|
+
}
|
|
453
488
|
// #1459: how long ONE auxiliary notify write may stay outstanding before the
|
|
454
489
|
// server counts as wedged rather than merely slow. A scanner whose per-file work
|
|
455
490
|
// exceeds the write budget is normal (opengrep routinely needs >2s on a large
|
|
@@ -461,6 +496,30 @@ const NOTIFY_WEDGED_BUDGET_MULTIPLIER = 5;
|
|
|
461
496
|
function notifyWedgedMs() {
|
|
462
497
|
return notifyWriteBudgetMs() * NOTIFY_WEDGED_BUDGET_MULTIPLIER;
|
|
463
498
|
}
|
|
499
|
+
// #2358: the wedge window's teardown decision gained a liveness discriminator.
|
|
500
|
+
// The fixed window above stays the FLOOR; a server whose drain history says it
|
|
501
|
+
// answers per-write in `ewmaMs` with `unacked` documents queued earns
|
|
502
|
+
// `k x ewmaMs x unacked` of patience instead — the issue's "a server that
|
|
503
|
+
// historically answers in 855 ms with 8 writes queued earns 15-20 s". The cap
|
|
504
|
+
// bounds the whole thing so a busy-but-never-draining server still respawns.
|
|
505
|
+
const NOTIFY_WEDGED_EWMA_MULTIPLIER = 2;
|
|
506
|
+
const NOTIFY_WEDGED_CAP_MS = 60_000;
|
|
507
|
+
function notifyWedgedCapMs() {
|
|
508
|
+
const raw = Number(process.env.PI_LENS_LSP_NOTIFY_WEDGED_CAP_MS);
|
|
509
|
+
return Number.isFinite(raw) && raw > 0 ? raw : NOTIFY_WEDGED_CAP_MS;
|
|
510
|
+
}
|
|
511
|
+
// #2358: how long the CPU-liveness discriminator watches the server between
|
|
512
|
+
// two process-CPU samples before deciding "busy" vs "flat". Only ever spent
|
|
513
|
+
// on a server whose write is already past its patience window — never on the
|
|
514
|
+
// per-edit hot path.
|
|
515
|
+
function notifyStallCpuSampleMs() {
|
|
516
|
+
const raw = Number(process.env.PI_LENS_LSP_NOTIFY_STALL_CPU_SAMPLE_MS);
|
|
517
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 1200;
|
|
518
|
+
}
|
|
519
|
+
// A server burning more than this percent of one core across the sample
|
|
520
|
+
// window counts as BUSY (progressing), not flat (wedged). A single-threaded
|
|
521
|
+
// scanner under burst sits near 100; an idle or dead one near 0.
|
|
522
|
+
const NOTIFY_STALL_CPU_BUSY_FLOOR_PERCENT = 10;
|
|
464
523
|
// #1714: how many document notifies one auxiliary may hold UNACKNOWLEDGED
|
|
465
524
|
// before the next notify has to prove the server drained its input.
|
|
466
525
|
//
|
|
@@ -576,10 +635,12 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
|
|
|
576
635
|
}
|
|
577
636
|
// --- Service ---
|
|
578
637
|
export class LSPService {
|
|
638
|
+
sessionCwd;
|
|
579
639
|
state;
|
|
580
640
|
workspaceProbeLogged = new Set();
|
|
581
641
|
/** Per-service immutable root-boundary verdicts; root detectors cache hits too. */
|
|
582
642
|
projectBoundaryCache = new Map();
|
|
643
|
+
/** Foreign roots are recorded once per normalized root for this service/session. */
|
|
583
644
|
warmStartLogged = new Set();
|
|
584
645
|
optionalFailureLogged = new Set();
|
|
585
646
|
/** Server/root pairs that already emitted unavailable for the current occurrence. */
|
|
@@ -692,6 +753,22 @@ export class LSPService {
|
|
|
692
753
|
* successful write clears its entry.
|
|
693
754
|
*/
|
|
694
755
|
notifyWriteBackpressureStreak = new Map();
|
|
756
|
+
/**
|
|
757
|
+
* #2358: EWMA of one auxiliary's per-document drain latency
|
|
758
|
+
* (ms per unacknowledged write), keyed by "serverId:normalizedRoot".
|
|
759
|
+
*
|
|
760
|
+
* Updated from DRAINED notify barriers only: the round-trip proves the
|
|
761
|
+
* server processed its `outstanding` backlog, and
|
|
762
|
+
* duration / outstanding is the per-write service time a single-threaded
|
|
763
|
+
* scanner actually achieves under load. The breaker grants a wedged write
|
|
764
|
+
* `k x ewma x unacked` of patience from it, so a slow-but-alive scanner is
|
|
765
|
+
* not killed at the fixed window by construction (#2358).
|
|
766
|
+
*
|
|
767
|
+
* Cleared by the service teardown (via `session_start`) like the rest of
|
|
768
|
+
* the breaker state; a stale throughput estimate from a previous session's
|
|
769
|
+
* client must not price the next one.
|
|
770
|
+
*/
|
|
771
|
+
auxNotifyDrainLatencyEwma = new Map();
|
|
695
772
|
/**
|
|
696
773
|
* #1714: unacknowledged auxiliary document notifies per server key
|
|
697
774
|
* ("serverId:normalizedRoot" — the same identity as every other gate here).
|
|
@@ -728,6 +805,15 @@ export class LSPService {
|
|
|
728
805
|
* never lands is still demoted (see {@link demoteForNotifyStall}).
|
|
729
806
|
*/
|
|
730
807
|
outstandingAuxNotifyWrites = new Map();
|
|
808
|
+
/**
|
|
809
|
+
* #2356: auxiliary clients removed by the notify-stall breaker are a
|
|
810
|
+
* temporary absence, not a missing scanner. Keep the demotion identity after
|
|
811
|
+
* deleting the client so the turn-end late-coverage probe can re-arm the
|
|
812
|
+
* pending pair until a replacement is published (or its existing bounded
|
|
813
|
+
* re-arm ceiling is reached). A successful replacement removes the marker.
|
|
814
|
+
*/
|
|
815
|
+
static MAX_NOTIFY_STALL_DEMOTIONS = 50;
|
|
816
|
+
notifyStallDemotions = new BoundedFifoMap(LSPService.MAX_NOTIFY_STALL_DEMOTIONS);
|
|
731
817
|
/** LRU clock for capacity eviction, keyed by the canonical server/root key. */
|
|
732
818
|
clientLastUsedAt = new Map();
|
|
733
819
|
/**
|
|
@@ -755,8 +841,9 @@ export class LSPService {
|
|
|
755
841
|
* a permanent promise/microtask tax.
|
|
756
842
|
*/
|
|
757
843
|
generationHandoff;
|
|
758
|
-
constructor(generationHandoff) {
|
|
844
|
+
constructor(generationHandoff, sessionCwd) {
|
|
759
845
|
this.generationHandoff = generationHandoff;
|
|
846
|
+
this.sessionCwd = sessionCwd;
|
|
760
847
|
this.state = {
|
|
761
848
|
clients: new Map(),
|
|
762
849
|
servers: new Map(),
|
|
@@ -776,6 +863,14 @@ export class LSPService {
|
|
|
776
863
|
const candidate = await server.root(filePath);
|
|
777
864
|
if (!candidate)
|
|
778
865
|
return undefined;
|
|
866
|
+
// #2052: a file outside EVERY initialized session cwd gets no client at
|
|
867
|
+
// all. The ceiling below is unchanged (`process.cwd()`, as before this
|
|
868
|
+
// issue) and still only clamps files that are actually inside it —
|
|
869
|
+
// `enforceLspRootCeiling` returns the root untouched for an out-of-ceiling
|
|
870
|
+
// file. So this gate is the ONLY new refusal, and it consults the
|
|
871
|
+
// registry, not a single cwd.
|
|
872
|
+
if (isOutsideAllSessionRoots(filePath))
|
|
873
|
+
return undefined;
|
|
779
874
|
const root = enforceLspRootCeiling(candidate, process.cwd(), filePath);
|
|
780
875
|
if (normalizeMapKey(root) === normalizeMapKey(process.cwd()))
|
|
781
876
|
return root;
|
|
@@ -919,6 +1014,7 @@ export class LSPService {
|
|
|
919
1014
|
return false;
|
|
920
1015
|
}
|
|
921
1016
|
const [victimKey, victimClient] = victim;
|
|
1017
|
+
this.releaseOutstandingAuxNotifyWrite(victimKey);
|
|
922
1018
|
await victimClient.shutdown({ reason: "client_ceiling_lru" });
|
|
923
1019
|
this.state.clients.delete(victimKey);
|
|
924
1020
|
this.state.clientSpawnedAt.delete(victimKey);
|
|
@@ -935,6 +1031,15 @@ export class LSPService {
|
|
|
935
1031
|
clearTimeout(timer);
|
|
936
1032
|
this.typeScriptIdleTimers.delete(key);
|
|
937
1033
|
}
|
|
1034
|
+
/** Release a notify token and its timer when its client generation retires. */
|
|
1035
|
+
releaseOutstandingAuxNotifyWrite(key, token) {
|
|
1036
|
+
const current = this.outstandingAuxNotifyWrites.get(key);
|
|
1037
|
+
if (!current || (token !== undefined && current !== token))
|
|
1038
|
+
return;
|
|
1039
|
+
clearTimeout(current.wedgeTimer);
|
|
1040
|
+
this.outstandingAuxNotifyWrites.delete(key);
|
|
1041
|
+
current.resolveSettled();
|
|
1042
|
+
}
|
|
938
1043
|
scheduleTypeScriptIdleEviction(key) {
|
|
939
1044
|
if (!key.startsWith("typescript:"))
|
|
940
1045
|
return;
|
|
@@ -960,6 +1065,7 @@ export class LSPService {
|
|
|
960
1065
|
// Publish the cold state synchronously before awaiting teardown. A request
|
|
961
1066
|
// arriving while shutdown is in progress therefore waits on the spawn gate
|
|
962
1067
|
// and creates a fresh client; it can never receive this retiring one.
|
|
1068
|
+
this.releaseOutstandingAuxNotifyWrite(key);
|
|
963
1069
|
this.state.clients.delete(key);
|
|
964
1070
|
this.state.clientSpawnedAt.delete(key);
|
|
965
1071
|
this.state.demonstratedReady.delete(key);
|
|
@@ -1101,23 +1207,130 @@ export class LSPService {
|
|
|
1101
1207
|
return;
|
|
1102
1208
|
}
|
|
1103
1209
|
this.notifyWriteBackpressureStreak.delete(key);
|
|
1104
|
-
|
|
1210
|
+
// #2358: the consecutive-timeout ladder lands here too, and its verdict
|
|
1211
|
+
// must not kill a server that is demonstrably BUSY. The guarded demotion
|
|
1212
|
+
// samples the live process CPU and merely resets the ladder (the next
|
|
1213
|
+
// three timeouts re-try) when the server is progressing — the wedged-write
|
|
1214
|
+
// path's cap owns a busy-but-never-draining server, not this streak.
|
|
1215
|
+
void this.demoteNotifyStallCpuGuarded(key, entry, filePath, {
|
|
1105
1216
|
consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
|
|
1106
1217
|
});
|
|
1107
1218
|
}
|
|
1219
|
+
/**
|
|
1220
|
+
* #2358: liveness-guarded teardown for the STREAK ladder.
|
|
1221
|
+
*
|
|
1222
|
+
* The wedged-write path ({@link decideNotifyStallTeardown}) keeps its own
|
|
1223
|
+
* re-arm loop so a busy server's outstanding write stays un-torn-down; the
|
|
1224
|
+
* streak ladder has no write to hold — it simply does not demote, and the
|
|
1225
|
+
* next three timeouts re-attempt. Generation-guarded after the await so a
|
|
1226
|
+
* client replaced mid-sample is never demoted for a verdict about its
|
|
1227
|
+
* predecessor.
|
|
1228
|
+
*/
|
|
1229
|
+
async demoteNotifyStallCpuGuarded(key, entry, filePath, reason) {
|
|
1230
|
+
if (this.state.clients.get(key) !== entry.client)
|
|
1231
|
+
return;
|
|
1232
|
+
// The CPU verdict is asynchronous, but the streak has already committed to
|
|
1233
|
+
// this client's teardown path. Release the TypeScript idle-timer ownership
|
|
1234
|
+
// before sampling so another idle callback cannot target the same client
|
|
1235
|
+
// while the verdict is in flight. A BUSY verdict below re-arms a fresh timer.
|
|
1236
|
+
this.clearTypeScriptIdleTimer(key);
|
|
1237
|
+
const verdict = await this.notifyStallCpuVerdict(entry);
|
|
1238
|
+
if (verdict.cpuVerdict === "busy") {
|
|
1239
|
+
if (this.state.clients.get(key) === entry.client) {
|
|
1240
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
1241
|
+
}
|
|
1242
|
+
this.logNotifyStallCpuBusy(key, entry, filePath, 0, verdict);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (this.state.clients.get(key) !== entry.client)
|
|
1246
|
+
return;
|
|
1247
|
+
this.demoteForNotifyStall(key, entry, filePath, {
|
|
1248
|
+
...reason,
|
|
1249
|
+
discriminator: "consecutive-timeouts",
|
|
1250
|
+
cpuVerdict: verdict.cpuVerdict,
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* #2358: classify a stalled server's live process CPU.
|
|
1255
|
+
*
|
|
1256
|
+
* A client with no process handle (a test/mock double, a legacy client)
|
|
1257
|
+
* reports "unmeasured" — the caller keeps the pre-#2358 demote-at-budget
|
|
1258
|
+
* behavior, because there is no liveness evidence to override it. A sampled
|
|
1259
|
+
* process that burns a core is BUSY; anything else is FLAT, including a
|
|
1260
|
+
* query that failed to resolve the pid — a server we cannot measure is not
|
|
1261
|
+
* proven busy, and the replacement self-heal is cheap.
|
|
1262
|
+
*/
|
|
1263
|
+
async notifyStallCpuVerdict(entry) {
|
|
1264
|
+
const pid = entry.client.getProcessPid?.();
|
|
1265
|
+
if (pid === undefined || !(Number.isFinite(pid) && pid > 0)) {
|
|
1266
|
+
return { cpuVerdict: "unmeasured", cpuPercent: null };
|
|
1267
|
+
}
|
|
1268
|
+
const sample = await sampleProcessTreeCpuPercent(pid, notifyStallCpuSampleMs(), NOTIFY_STALL_CPU_BUSY_FLOOR_PERCENT);
|
|
1269
|
+
if (!sample.measured) {
|
|
1270
|
+
return { cpuVerdict: "unmeasured", cpuPercent: null };
|
|
1271
|
+
}
|
|
1272
|
+
if (sample.busy) {
|
|
1273
|
+
return { cpuVerdict: "busy", cpuPercent: sample.cpuPercent };
|
|
1274
|
+
}
|
|
1275
|
+
return { cpuVerdict: "flat", cpuPercent: sample.cpuPercent };
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* #2358: one bounded row per busy defer. Not a failure record — the
|
|
1279
|
+
* discrimination succeeded — so it is a plain latency phase; its volume is
|
|
1280
|
+
* bounded by the re-arm cadence of one outstanding write (at most the wedge
|
|
1281
|
+
* cap divided by the budget, ~6 fires at defaults).
|
|
1282
|
+
*/
|
|
1283
|
+
logNotifyStallCpuBusy(key, entry, filePath, outstandingMs, verdict, extra = {}) {
|
|
1284
|
+
emitBounded("lsp_notify_stall_cpu_busy", `${key}:${normalizeMapKey(filePath)}`, {
|
|
1285
|
+
type: "phase",
|
|
1286
|
+
durationMs: outstandingMs,
|
|
1287
|
+
metadata: {
|
|
1288
|
+
serverId: entry.info.id,
|
|
1289
|
+
clientKey: key,
|
|
1290
|
+
outstandingMs,
|
|
1291
|
+
cpuPercent: verdict.cpuPercent ?? null,
|
|
1292
|
+
...extra,
|
|
1293
|
+
},
|
|
1294
|
+
}, {
|
|
1295
|
+
ledgerKind: "lsp-notify-stall-cpu-busy",
|
|
1296
|
+
risingEdgePer: "identity",
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* #2358: the patience window a wedged auxiliary write earns.
|
|
1301
|
+
*
|
|
1302
|
+
* max(fixed floor, k x EWMA per-write latency x unacked depth), capped so a
|
|
1303
|
+
* busy-but-never-draining server still respawns within the cap. The EWMA
|
|
1304
|
+
* comes from DRAINED notify barriers (see {@link noteAuxNotifyDrainLatency});
|
|
1305
|
+
* with no history the fixed floor (the pre-#2358 window) stands.
|
|
1306
|
+
*/
|
|
1307
|
+
auxNotifyWedgeBudgetMs(key) {
|
|
1308
|
+
let budgetMs = notifyWedgedMs();
|
|
1309
|
+
const ewma = this.auxNotifyDrainLatencyEwma.get(key);
|
|
1310
|
+
if (ewma !== undefined && ewma > 0) {
|
|
1311
|
+
const unacked = this.auxNotifyInflight.get(key)?.unacked ?? 0;
|
|
1312
|
+
budgetMs = Math.max(budgetMs, NOTIFY_WEDGED_EWMA_MULTIPLIER * ewma * unacked);
|
|
1313
|
+
}
|
|
1314
|
+
return Math.min(budgetMs, notifyWedgedCapMs());
|
|
1315
|
+
}
|
|
1108
1316
|
/**
|
|
1109
1317
|
* The demotion itself, shared by the #743 consecutive-timeout streak and the
|
|
1110
1318
|
* #1459 wedged-write rule. Both mean the same thing — this client's input path
|
|
1111
1319
|
* is not moving — and both need the same teardown.
|
|
1112
1320
|
*/
|
|
1113
1321
|
demoteForNotifyStall(key, entry, filePath, reason) {
|
|
1322
|
+
// An async verdict belongs to one client generation. Never let a
|
|
1323
|
+
// predecessor's decision delete or cool down its replacement.
|
|
1324
|
+
if (this.state.clients.get(key) !== entry.client)
|
|
1325
|
+
return;
|
|
1114
1326
|
this.notifyWriteBackpressureStreak.delete(key);
|
|
1115
|
-
this.
|
|
1327
|
+
this.releaseOutstandingAuxNotifyWrite(key);
|
|
1116
1328
|
// #1714: the demoted client is torn down, so its backlog count describes a
|
|
1117
1329
|
// process that no longer exists. Leaving it would make the replacement start
|
|
1118
1330
|
// at the ceiling and pay a barrier on its first file.
|
|
1119
1331
|
this.auxNotifyInflight.delete(key);
|
|
1120
1332
|
this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
|
|
1333
|
+
this.notifyStallDemotions.set(key, Date.now());
|
|
1121
1334
|
void entry.client.shutdown().catch(() => { });
|
|
1122
1335
|
this.state.clients.delete(key);
|
|
1123
1336
|
this.state.clientSpawnedAt.delete(key);
|
|
@@ -1180,6 +1393,87 @@ export class LSPService {
|
|
|
1180
1393
|
}
|
|
1181
1394
|
this.auxNotifyInflight.set(key, { client, unacked: 1 });
|
|
1182
1395
|
}
|
|
1396
|
+
/**
|
|
1397
|
+
* #2358: fold one DRAINED notify barrier's per-write latency into the
|
|
1398
|
+
* per-key EWMA. A drained round-trip proves the server processed its
|
|
1399
|
+
* `outstanding` backlog after the ping; duration / outstanding is the
|
|
1400
|
+
* per-document service time the single-threaded scanner actually achieves
|
|
1401
|
+
* under load, which is exactly what a busy-server patience window must be
|
|
1402
|
+
* priced from (#2358's evidence: an 855 ms per-answer server with an
|
|
1403
|
+
* 8-write backlog, able to drain in 10-20 s while healthy).
|
|
1404
|
+
*/
|
|
1405
|
+
noteAuxNotifyDrainLatency(key, durationMs, outstanding, record) {
|
|
1406
|
+
if (!(outstanding > 0 && durationMs > 0))
|
|
1407
|
+
return;
|
|
1408
|
+
if (record && this.auxNotifyInflight.get(key) !== record)
|
|
1409
|
+
return;
|
|
1410
|
+
const perWriteMs = durationMs / outstanding;
|
|
1411
|
+
const prev = this.auxNotifyDrainLatencyEwma.get(key);
|
|
1412
|
+
this.auxNotifyDrainLatencyEwma.set(key, prev === undefined ? perWriteMs : 0.5 * perWriteMs + 0.5 * prev);
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* #2358: decide whether a wedged notify write's teardown really fires.
|
|
1416
|
+
*
|
|
1417
|
+
* Before #2358 the breaker killed a server whose write stayed outstanding
|
|
1418
|
+
* past a FIXED window, conflating a dead input path with a busy scanner
|
|
1419
|
+
* draining a burst — the live opengrep kill at #2358's head. The decision
|
|
1420
|
+
* now has two guards, and the record names which one fired:
|
|
1421
|
+
*
|
|
1422
|
+
* - the window is ADAPTIVE — max(fixed floor, k x EWMA per-write latency x
|
|
1423
|
+
* unacked depth), capped at {@link notifyWedgedCapMs} — so a server that
|
|
1424
|
+
* historically answers in 855 ms with 8 writes queued earns 13.7 s (k=2),
|
|
1425
|
+
* not 10 s;
|
|
1426
|
+
* - past the window, the server's process CPU is sampled twice; a BUSY
|
|
1427
|
+
* server is left alone (the write stays outstanding and the caller re-arms
|
|
1428
|
+
* this timer), a FLAT or unmeasured one is torn down.
|
|
1429
|
+
*
|
|
1430
|
+
* The hard cap still kills: a busy server that cannot drain inside it is
|
|
1431
|
+
* damaged, and the replacement is the self-heal the breaker exists to
|
|
1432
|
+
* provide.
|
|
1433
|
+
*/
|
|
1434
|
+
async decideNotifyStallTeardown(key, entry, filePath, token) {
|
|
1435
|
+
if (this.state.clients.get(key) !== entry.client ||
|
|
1436
|
+
this.outstandingAuxNotifyWrites.get(key) !== token) {
|
|
1437
|
+
throw new Error("stale notify generation");
|
|
1438
|
+
}
|
|
1439
|
+
const outstandingMs = Date.now() - token.startedAt;
|
|
1440
|
+
const armedBudgetMs = token.armedBudgetMs;
|
|
1441
|
+
if (outstandingMs >= notifyWedgedCapMs()) {
|
|
1442
|
+
return {
|
|
1443
|
+
action: "demote",
|
|
1444
|
+
reason: {
|
|
1445
|
+
outstandingMs,
|
|
1446
|
+
discriminator: "cap-exceeded",
|
|
1447
|
+
budgetMs: armedBudgetMs,
|
|
1448
|
+
},
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
const verdict = await this.notifyStallCpuVerdict(entry);
|
|
1452
|
+
if (this.state.clients.get(key) !== entry.client ||
|
|
1453
|
+
this.outstandingAuxNotifyWrites.get(key) !== token) {
|
|
1454
|
+
throw new Error("stale notify generation");
|
|
1455
|
+
}
|
|
1456
|
+
if (verdict.cpuVerdict === "busy") {
|
|
1457
|
+
this.logNotifyStallCpuBusy(key, entry, filePath, outstandingMs, verdict, {
|
|
1458
|
+
budgetMs: armedBudgetMs,
|
|
1459
|
+
});
|
|
1460
|
+
return { action: "rearm", budgetMs: this.auxNotifyWedgeBudgetMs(key) };
|
|
1461
|
+
}
|
|
1462
|
+
return {
|
|
1463
|
+
action: "demote",
|
|
1464
|
+
reason: {
|
|
1465
|
+
outstandingMs,
|
|
1466
|
+
discriminator: verdict.cpuVerdict === "unmeasured"
|
|
1467
|
+
? "budget-exceeded"
|
|
1468
|
+
: "budget-exceeded-cpu-flat",
|
|
1469
|
+
budgetMs: armedBudgetMs,
|
|
1470
|
+
ewmaInputMs: this.auxNotifyDrainLatencyEwma.get(key),
|
|
1471
|
+
unackedDepth: this.auxNotifyInflight.get(key)?.unacked ?? 0,
|
|
1472
|
+
cpuVerdict: verdict.cpuVerdict,
|
|
1473
|
+
cpuPercent: verdict.cpuPercent ?? null,
|
|
1474
|
+
},
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1183
1477
|
/**
|
|
1184
1478
|
* #1714: is this auxiliary already holding as many documents as it may?
|
|
1185
1479
|
*
|
|
@@ -1310,11 +1604,17 @@ export class LSPService {
|
|
|
1310
1604
|
// and the waiter is what latches. A second latch here would be
|
|
1311
1605
|
// unreachable, and no test could hold it honest.
|
|
1312
1606
|
}
|
|
1607
|
+
// #2358: a DRAINED round-trip is a per-write latency measurement —
|
|
1608
|
+
// the server really processed its backlog — so it feeds the EWMA
|
|
1609
|
+
// the adaptive wedge window is priced from.
|
|
1610
|
+
const durationMs = Date.now() - startedAt;
|
|
1611
|
+
if (drained)
|
|
1612
|
+
this.noteAuxNotifyDrainLatency(key, durationMs, outstanding, record);
|
|
1313
1613
|
this.noteDrainBarrierOutcome(key, entry, filePath, context, {
|
|
1314
1614
|
unacked: outstanding,
|
|
1315
1615
|
limit,
|
|
1316
1616
|
waitMs,
|
|
1317
|
-
durationMs
|
|
1617
|
+
durationMs,
|
|
1318
1618
|
outcome: drained ? "drained" : "stalled",
|
|
1319
1619
|
});
|
|
1320
1620
|
return drained;
|
|
@@ -1437,7 +1737,7 @@ export class LSPService {
|
|
|
1437
1737
|
// respawned) says nothing about this client's stdin — drop it, so a stale
|
|
1438
1738
|
// entry can never starve a healthy server.
|
|
1439
1739
|
if (outstanding && outstanding.client !== entry.client) {
|
|
1440
|
-
this.
|
|
1740
|
+
this.releaseOutstandingAuxNotifyWrite(clientKey, outstanding);
|
|
1441
1741
|
}
|
|
1442
1742
|
else if (outstanding) {
|
|
1443
1743
|
const outstandingMs = Date.now() - outstanding.startedAt;
|
|
@@ -1474,30 +1774,70 @@ export class LSPService {
|
|
|
1474
1774
|
startedAt: Date.now(),
|
|
1475
1775
|
client: entry.client,
|
|
1476
1776
|
settled,
|
|
1477
|
-
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1777
|
+
armedBudgetMs: 0,
|
|
1778
|
+
// SAFETY: the handle is a torn-initialized placeholder. It is armed
|
|
1779
|
+
// a beat later in this same atomic claim (no await in between) and
|
|
1780
|
+
// thereafter only ever written by this token's own fire/release
|
|
1781
|
+
// closures, so the placeholder value is never read by anyone.
|
|
1782
|
+
wedgeTimer: undefined,
|
|
1783
|
+
resolveSettled: () => resolveSettled?.(),
|
|
1784
|
+
};
|
|
1785
|
+
// A write nothing accepts for the whole wedge window is a dead input
|
|
1786
|
+
// path, not a slow scan. Armed HERE rather than checked by the next
|
|
1787
|
+
// waiter: inside a burst every waiter arrives within one budget, so a
|
|
1788
|
+
// waiter-side check could never see the wedge window elapse and a
|
|
1789
|
+
// wedged scanner was never demoted. Unref'd so it cannot hold a
|
|
1790
|
+
// one-shot host alive, and cleared on release.
|
|
1791
|
+
//
|
|
1792
|
+
// #2358: the window itself is now ADAPTIVE (issue's `max(fixed,
|
|
1793
|
+
// k x EWMA per-answer latency x unacked depth)`, capped), and when it
|
|
1794
|
+
// fires the server's process CPU is sampled before the kill — a busy
|
|
1795
|
+
// server is left alone and the timer re-arms, so a scanner draining a
|
|
1796
|
+
// burst is never killed by construction. Every fire re-checks the
|
|
1797
|
+
// token identity after its awaits, so a release or a concurrent
|
|
1798
|
+
// demotion that lands mid-sample aborts the decision.
|
|
1799
|
+
const fireWedge = async () => {
|
|
1800
|
+
if (this.outstandingAuxNotifyWrites.get(clientKey) !== token ||
|
|
1801
|
+
this.state.clients.get(clientKey) !== entry.client) {
|
|
1802
|
+
this.releaseOutstandingAuxNotifyWrite(clientKey, token);
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
let decision;
|
|
1806
|
+
try {
|
|
1807
|
+
decision = await this.decideNotifyStallTeardown(clientKey, entry, filePath, token);
|
|
1808
|
+
}
|
|
1809
|
+
catch {
|
|
1810
|
+
// A failed decision must not strand the outstanding write or the
|
|
1811
|
+
// waiter: fall back to the pre-#2358 demote-at-budget teardown.
|
|
1812
|
+
decision = {
|
|
1813
|
+
action: "demote",
|
|
1814
|
+
reason: {
|
|
1815
|
+
outstandingMs: Date.now() - token.startedAt,
|
|
1816
|
+
discriminator: "budget-exceeded",
|
|
1817
|
+
budgetMs: token.armedBudgetMs,
|
|
1818
|
+
},
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
if (this.outstandingAuxNotifyWrites.get(clientKey) !== token ||
|
|
1822
|
+
this.state.clients.get(clientKey) !== entry.client) {
|
|
1823
|
+
this.releaseOutstandingAuxNotifyWrite(clientKey, token);
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
if (decision.action === "demote") {
|
|
1827
|
+
this.demoteForNotifyStall(clientKey, entry, filePath, decision.reason);
|
|
1489
1828
|
resolveSettled?.();
|
|
1490
|
-
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
token.wedgeTimer = setTimeout(() => void fireWedge(), decision.budgetMs);
|
|
1832
|
+
token.wedgeTimer.unref?.();
|
|
1491
1833
|
};
|
|
1834
|
+
token.armedBudgetMs = this.auxNotifyWedgeBudgetMs(clientKey);
|
|
1835
|
+
token.wedgeTimer = setTimeout(() => void fireWedge(), token.armedBudgetMs);
|
|
1492
1836
|
token.wedgeTimer.unref?.();
|
|
1493
1837
|
this.outstandingAuxNotifyWrites.set(clientKey, token);
|
|
1494
1838
|
return {
|
|
1495
1839
|
release: () => {
|
|
1496
|
-
|
|
1497
|
-
if (this.outstandingAuxNotifyWrites.get(clientKey) === token) {
|
|
1498
|
-
this.outstandingAuxNotifyWrites.delete(clientKey);
|
|
1499
|
-
}
|
|
1500
|
-
resolveSettled?.();
|
|
1840
|
+
this.releaseOutstandingAuxNotifyWrite(clientKey, token);
|
|
1501
1841
|
},
|
|
1502
1842
|
};
|
|
1503
1843
|
}
|
|
@@ -1578,6 +1918,30 @@ export class LSPService {
|
|
|
1578
1918
|
return entries.sort((a, b) => (priority.get(a.serverId) ?? Number.MAX_SAFE_INTEGER) -
|
|
1579
1919
|
(priority.get(b.serverId) ?? Number.MAX_SAFE_INTEGER));
|
|
1580
1920
|
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Documents currently open on any live client rooted at or under `cwd`
|
|
1923
|
+
* (#2430), as normalized path keys.
|
|
1924
|
+
*
|
|
1925
|
+
* The third contributor to the observational mutation net's tracked-file
|
|
1926
|
+
* set, alongside the read-guard's read/write set and the widget's diagnostic
|
|
1927
|
+
* files. It is pure in-memory enumeration — no spawn, no open, no stat —
|
|
1928
|
+
* and it is deliberately capped by the caller rather than here, because the
|
|
1929
|
+
* cap belongs to the sweep's budget, not to the pool's bookkeeping.
|
|
1930
|
+
*/
|
|
1931
|
+
getOpenDocumentPaths(cwd) {
|
|
1932
|
+
const paths = new Set();
|
|
1933
|
+
for (const { client } of this.activeClientsForCwd(cwd)) {
|
|
1934
|
+
try {
|
|
1935
|
+
for (const filePath of client.openDocumentPaths?.() ?? [])
|
|
1936
|
+
paths.add(filePath);
|
|
1937
|
+
}
|
|
1938
|
+
catch {
|
|
1939
|
+
// A client torn down mid-enumeration contributes nothing; the sweep
|
|
1940
|
+
// is advisory and must never break on a dying server.
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
return [...paths];
|
|
1944
|
+
}
|
|
1581
1945
|
/**
|
|
1582
1946
|
* Get or create LSP client for a file
|
|
1583
1947
|
* Prevents duplicate client creation via in-flight promise tracking
|
|
@@ -1588,14 +1952,76 @@ export class LSPService {
|
|
|
1588
1952
|
// Primary selection considers language servers only — auxiliary servers
|
|
1589
1953
|
// (opengrep, …) attach alongside the primary and are never chosen as it.
|
|
1590
1954
|
const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary");
|
|
1591
|
-
|
|
1955
|
+
// #2525: candidate roots are resolved LAZILY, and at most once per call.
|
|
1956
|
+
// They are deliberately NOT hoisted: `NearestRoot` (clients/lsp/server.ts)
|
|
1957
|
+
// caches successful hits but never negatives, so a fallback that cannot
|
|
1958
|
+
// root here — DenoServer (`fallbackFor: "typescript"`, gated on
|
|
1959
|
+
// deno.json(c)) in a project with no deno.json — re-walks to the
|
|
1960
|
+
// filesystem root on EVERY call, by design, so that a deno.json
|
|
1961
|
+
// scaffolded mid-session is picked up without a restart. Hoisting would
|
|
1962
|
+
// charge that walk to every touch of every .ts file in every non-Deno
|
|
1963
|
+
// project. The sequential trial loop below needs no roots of its own
|
|
1964
|
+
// (`ensureClientForServer` resolves and bails at `!root` before any spawn
|
|
1965
|
+
// work), so a served touch — the hot path — resolves nothing extra at
|
|
1966
|
+
// all. Only the branches that EMIT a candidate list ask, and all of them
|
|
1967
|
+
// are cold paths.
|
|
1968
|
+
const rootMemo = new Map();
|
|
1969
|
+
/**
|
|
1970
|
+
* Split the candidates into the ones that could actually have been waited
|
|
1971
|
+
* on and the ones that never had a spawn slot. A record's subject must be
|
|
1972
|
+
* its producer (#1550) — the #2524 shape.
|
|
1973
|
+
*
|
|
1974
|
+
* #2525 round 3 (F3): no SHIPPED `server.root` rejects today — every one
|
|
1975
|
+
* bottoms out in `markerExists`, which only ever resolves `false` on a
|
|
1976
|
+
* miss — so this was reasoned "removed by construction". A reviewer probe
|
|
1977
|
+
* disproved that: a `root()` that rejects makes this `Promise.all` reject,
|
|
1978
|
+
* which throws out of `getClientForFile` itself (the caller never gets
|
|
1979
|
+
* `undefined` back, and `lsp_client_wait_timeout` is never emitted). A
|
|
1980
|
+
* per-server catch degrades that candidate to unrooted instead of taking
|
|
1981
|
+
* the whole partition down — the same "producer" the memo already knows
|
|
1982
|
+
* (a failed root resolution never got a client either), now WITH the
|
|
1983
|
+
* failure reason so the record says why, not just that.
|
|
1984
|
+
*/
|
|
1985
|
+
const partitionCandidates = async () => {
|
|
1986
|
+
const resolved = await Promise.all(servers.map(async (server) => {
|
|
1987
|
+
try {
|
|
1988
|
+
return {
|
|
1989
|
+
server,
|
|
1990
|
+
root: await this.resolveServerRootOnce(server, filePath, rootMemo),
|
|
1991
|
+
reason: undefined,
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
catch (error) {
|
|
1995
|
+
return {
|
|
1996
|
+
server,
|
|
1997
|
+
root: undefined,
|
|
1998
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
}));
|
|
2002
|
+
const unrootedReasons = {};
|
|
2003
|
+
for (const entry of resolved) {
|
|
2004
|
+
if (entry.root === undefined && entry.reason !== undefined) {
|
|
2005
|
+
unrootedReasons[entry.server.id] = entry.reason;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
return {
|
|
2009
|
+
rooted: resolved
|
|
2010
|
+
.filter((entry) => entry.root !== undefined)
|
|
2011
|
+
.map(({ server, root }) => ({ server, root })),
|
|
2012
|
+
unrootedIds: resolved
|
|
2013
|
+
.filter((entry) => entry.root === undefined)
|
|
2014
|
+
.map((entry) => entry.server.id),
|
|
2015
|
+
unrootedReasons,
|
|
2016
|
+
};
|
|
2017
|
+
};
|
|
1592
2018
|
// hardCapMs is a caller-imposed ceiling (e.g. pipeline budget) that
|
|
1593
2019
|
// prevents tool_result from blocking the TUI for the full LSP cold-start
|
|
1594
2020
|
// window. When no server config sets a wait (serverWaitOverrideMs = 0),
|
|
1595
2021
|
// hardCapMs is used directly — Math.min(0, cap) = 0 would otherwise
|
|
1596
2022
|
// take the no-timeout branch and block indefinitely (e.g. pyright, which
|
|
1597
2023
|
// has no clientWaitTimeoutMs but can take 30s to initialize on cold start).
|
|
1598
|
-
const serverBaseMs =
|
|
2024
|
+
const serverBaseMs = primaryServerWaitFloorMs(filePath, maxWaitMs);
|
|
1599
2025
|
const effectiveMaxWaitMs = hardCapMs !== undefined
|
|
1600
2026
|
? serverBaseMs > 0
|
|
1601
2027
|
? Math.min(serverBaseMs, hardCapMs)
|
|
@@ -1621,8 +2047,15 @@ export class LSPService {
|
|
|
1621
2047
|
// #1934: the first server whose acquisition ERRORED, as opposed to
|
|
1622
2048
|
// cleanly declining. Kept so a selection that served nobody still
|
|
1623
2049
|
// says which server the pool actually tried and failed to spawn.
|
|
2050
|
+
// #2064 carries the errored outcome VALUE with it, so the record
|
|
2051
|
+
// below reports whether this caller started the failed spawn or
|
|
2052
|
+
// joined it, instead of pinning a starter label on every joiner.
|
|
1624
2053
|
let erroredServerId;
|
|
1625
|
-
|
|
2054
|
+
let erroredOutcome;
|
|
2055
|
+
// Try each matching server in registration order.
|
|
2056
|
+
// `ensureClientForServer` resolves the root itself and returns before
|
|
2057
|
+
// any spawn work when there is none, so an unrooted fallback costs
|
|
2058
|
+
// this loop one already-memoized resolution and no spawn slot.
|
|
1626
2059
|
for (const server of servers) {
|
|
1627
2060
|
// A box, not a `let`: control-flow analysis cannot see the callback
|
|
1628
2061
|
// write and would narrow a plain local to its initializer.
|
|
@@ -1631,7 +2064,7 @@ export class LSPService {
|
|
|
1631
2064
|
};
|
|
1632
2065
|
const spawned = await this.ensureClientForServer(filePath, server, resolvedRoots, noteSpawnInFlight, (reported) => {
|
|
1633
2066
|
acquisition.outcome = reported;
|
|
1634
|
-
});
|
|
2067
|
+
}, rootMemo);
|
|
1635
2068
|
if (spawned) {
|
|
1636
2069
|
logLatency({
|
|
1637
2070
|
type: "phase",
|
|
@@ -1650,8 +2083,12 @@ export class LSPService {
|
|
|
1650
2083
|
});
|
|
1651
2084
|
return spawned;
|
|
1652
2085
|
}
|
|
1653
|
-
if (acquisition.outcome === "spawn-failure"
|
|
1654
|
-
|
|
2086
|
+
if (acquisition.outcome === "spawn-failure" ||
|
|
2087
|
+
acquisition.outcome === "spawn-failure-joined") {
|
|
2088
|
+
if (erroredServerId === undefined) {
|
|
2089
|
+
erroredServerId = server.id;
|
|
2090
|
+
erroredOutcome = acquisition.outcome;
|
|
2091
|
+
}
|
|
1655
2092
|
}
|
|
1656
2093
|
}
|
|
1657
2094
|
if (erroredServerId !== undefined) {
|
|
@@ -1668,12 +2105,21 @@ export class LSPService {
|
|
|
1668
2105
|
metadata: {
|
|
1669
2106
|
serverId: erroredServerId,
|
|
1670
2107
|
candidateCount: servers.length,
|
|
1671
|
-
outcome:
|
|
2108
|
+
outcome: (erroredOutcome ??
|
|
2109
|
+
"spawn-failure"),
|
|
1672
2110
|
},
|
|
1673
2111
|
});
|
|
1674
2112
|
}
|
|
2113
|
+
// #2525: reuse the roots the trial loop above already resolved rather
|
|
2114
|
+
// than re-walking with the RAW `server.root`. Two reasons: the raw
|
|
2115
|
+
// call skips the ceiling/session-root gates, so this dedupe key could
|
|
2116
|
+
// disagree with the identity the acquisition path actually used; and
|
|
2117
|
+
// on the unserved path — the only path that reaches here — every
|
|
2118
|
+
// unrooted candidate would pay a second full walk to the filesystem
|
|
2119
|
+
// root, on every touch, before the `unavailableLogged` dedupe even
|
|
2120
|
+
// looked at it.
|
|
1675
2121
|
const unavailable = (await Promise.all(servers.map(async (server) => {
|
|
1676
|
-
const root = await
|
|
2122
|
+
const root = await this.resolveServerRootOnce(server, filePath, rootMemo);
|
|
1677
2123
|
return {
|
|
1678
2124
|
server,
|
|
1679
2125
|
key: `${server.id}:${root ? normalizeMapKey(root) : "<unresolved>"}`,
|
|
@@ -1729,11 +2175,13 @@ export class LSPService {
|
|
|
1729
2175
|
// `inFlight` is cleared in ensureClientForServer's finally block, so a
|
|
1730
2176
|
// settled acquisition can still be present here. Re-read the published
|
|
1731
2177
|
// clients at the decision point; a usable client outranks the sentinel.
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
2178
|
+
// #2525: this is a cold path, so it is where the roots get resolved —
|
|
2179
|
+
// once, memoized with the trial loop's own resolutions. An unrooted
|
|
2180
|
+
// fallback can never have a client keyed under its (non-existent)
|
|
2181
|
+
// root, so it is neither probed here nor named in the record below.
|
|
2182
|
+
const { rooted, unrootedIds, unrootedReasons } = await partitionCandidates();
|
|
2183
|
+
for (const { server, root } of rooted) {
|
|
2184
|
+
const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
|
|
1737
2185
|
if (client?.isAlive())
|
|
1738
2186
|
return { client, info: server };
|
|
1739
2187
|
}
|
|
@@ -1745,16 +2193,46 @@ export class LSPService {
|
|
|
1745
2193
|
durationMs: 0,
|
|
1746
2194
|
metadata: {
|
|
1747
2195
|
maxWaitMs: effectiveMaxWaitMs,
|
|
1748
|
-
serverIds:
|
|
2196
|
+
serverIds: rooted.map(({ server }) => server.id),
|
|
2197
|
+
unrootedCandidates: {
|
|
2198
|
+
count: unrootedIds.length,
|
|
2199
|
+
ids: unrootedIds,
|
|
2200
|
+
...(Object.keys(unrootedReasons).length > 0
|
|
2201
|
+
? { reasons: unrootedReasons }
|
|
2202
|
+
: {}),
|
|
2203
|
+
},
|
|
1749
2204
|
reason: "budget_skipped_known_slow",
|
|
1750
2205
|
},
|
|
1751
2206
|
});
|
|
1752
2207
|
return undefined;
|
|
1753
2208
|
}
|
|
1754
2209
|
if (waitResult === timeoutSentinel) {
|
|
2210
|
+
// #2525: likewise a cold path — resolve here, not up front.
|
|
2211
|
+
//
|
|
2212
|
+
// #2525 round 3 (L2): `partitionCandidates` does UNCACHED negative
|
|
2213
|
+
// root walks (`NearestRoot` never caches misses, by design — see the
|
|
2214
|
+
// comment above `rootMemo`) and this branch only reaches it AFTER
|
|
2215
|
+
// `effectiveMaxWaitMs` has already elapsed. Measured by the reviewer:
|
|
2216
|
+
// 2.06ms native, ~150ms on the #462 slow-FS profile — unbounded on top
|
|
2217
|
+
// of a budget whose entire contract is "return within the cap". Bound
|
|
2218
|
+
// it with a small ceiling of its own (a quarter of the caller's hard
|
|
2219
|
+
// cap when one was given, capped at 250ms) rather than leaving it the
|
|
2220
|
+
// one piece of async work in this function with no bound at all
|
|
2221
|
+
// (the "async work needs both bounds" shape — here there is no
|
|
2222
|
+
// per-call abort signal to race, only the timer bound applies).
|
|
2223
|
+
const partitionBudgetMs = hardCapMs !== undefined ? Math.min(250, hardCapMs / 4) : 250;
|
|
2224
|
+
const partitioned = await withDeadline(partitionCandidates(), {
|
|
2225
|
+
ms: partitionBudgetMs,
|
|
2226
|
+
onTimeout: "undefined",
|
|
2227
|
+
});
|
|
2228
|
+
const { rooted, unrootedIds, unrootedReasons } = partitioned ?? {
|
|
2229
|
+
rooted: [],
|
|
2230
|
+
unrootedIds: [],
|
|
2231
|
+
unrootedReasons: {},
|
|
2232
|
+
};
|
|
1755
2233
|
// Snapshot known client health — scan by serverId prefix (no root needed)
|
|
1756
2234
|
const knownHealth = [...this.state.clients.entries()]
|
|
1757
|
-
.filter(([k]) =>
|
|
2235
|
+
.filter(([k]) => rooted.some(({ server }) => k.startsWith(`${server.id}:`)))
|
|
1758
2236
|
.map(([k, c]) => ({
|
|
1759
2237
|
serverId: k.split(":")[0],
|
|
1760
2238
|
alive: c.isAlive(),
|
|
@@ -1767,9 +2245,23 @@ export class LSPService {
|
|
|
1767
2245
|
durationMs: effectiveMaxWaitMs,
|
|
1768
2246
|
metadata: {
|
|
1769
2247
|
maxWaitMs: effectiveMaxWaitMs,
|
|
1770
|
-
serverIds
|
|
2248
|
+
// `serverIds` names the ROOTED pool the timeout record reports
|
|
2249
|
+
// against, not "servers this call actually waited on" — the
|
|
2250
|
+
// sequential trial loop can time out having reached only the
|
|
2251
|
+
// first of several rooted candidates (case 2 in the regression
|
|
2252
|
+
// test pins both `["typescript","deno"]` even though the loop
|
|
2253
|
+
// never got past `typescript`).
|
|
2254
|
+
serverIds: rooted.map(({ server }) => server.id),
|
|
2255
|
+
unrootedCandidates: {
|
|
2256
|
+
count: unrootedIds.length,
|
|
2257
|
+
ids: unrootedIds,
|
|
2258
|
+
...(Object.keys(unrootedReasons).length > 0
|
|
2259
|
+
? { reasons: unrootedReasons }
|
|
2260
|
+
: {}),
|
|
2261
|
+
},
|
|
1771
2262
|
// servers absent from knownHealth were never spawned or are still spawning
|
|
1772
2263
|
knownClientHealth: knownHealth,
|
|
2264
|
+
...(partitioned === undefined ? { partition: "timed-out" } : {}),
|
|
1773
2265
|
},
|
|
1774
2266
|
});
|
|
1775
2267
|
return undefined;
|
|
@@ -1777,8 +2269,10 @@ export class LSPService {
|
|
|
1777
2269
|
return waitResult;
|
|
1778
2270
|
}
|
|
1779
2271
|
/**
|
|
1780
|
-
* Get or create
|
|
1781
|
-
*
|
|
2272
|
+
* Get or create all complementary LSP clients that can serve a file. Alternate
|
|
2273
|
+
* language servers marked with `fallbackFor` remain sequential fallbacks: an
|
|
2274
|
+
* aggregate diagnostics pass must not launch them beside a working preferred
|
|
2275
|
+
* server merely because it requested `clientScope: "all"`.
|
|
1782
2276
|
*/
|
|
1783
2277
|
async getClientsForFile(filePath, excludeServerIds, resolvedRoots) {
|
|
1784
2278
|
const allServers = getServersForFileWithConfig(filePath);
|
|
@@ -1787,28 +2281,59 @@ export class LSPService {
|
|
|
1787
2281
|
: allServers;
|
|
1788
2282
|
if (servers.length === 0)
|
|
1789
2283
|
return { clients: [], serverCountAttempted: 0 };
|
|
1790
|
-
//
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
2284
|
+
// Resolve once to keep the attempted count tied to servers with a real root.
|
|
2285
|
+
const rootedServers = (await Promise.all(servers.map(async (server) => ({
|
|
2286
|
+
server,
|
|
2287
|
+
root: await this.resolveServerRoot(server, filePath),
|
|
2288
|
+
})))).filter((entry) => entry.root !== undefined);
|
|
2289
|
+
let serverCountAttempted = 0;
|
|
2290
|
+
const acquisitions = new Map();
|
|
2291
|
+
const acquire = (server) => {
|
|
2292
|
+
serverCountAttempted += 1;
|
|
2293
|
+
return this.ensureClientForServer(filePath, server, resolvedRoots);
|
|
1798
2294
|
};
|
|
2295
|
+
// Start complementary servers immediately. An alternate waits only for its
|
|
2296
|
+
// own preferred server, not for unrelated scanners, and starts if that server
|
|
2297
|
+
// declines. Registry order makes chained fallbacks deterministic.
|
|
2298
|
+
for (const { server } of rootedServers) {
|
|
2299
|
+
const preferred = server.fallbackFor
|
|
2300
|
+
? acquisitions.get(server.fallbackFor)
|
|
2301
|
+
: undefined;
|
|
2302
|
+
const acquisition = preferred
|
|
2303
|
+
? preferred.then((entry) => entry === undefined ? acquire(server) : undefined)
|
|
2304
|
+
: acquire(server);
|
|
2305
|
+
acquisitions.set(server.id, acquisition);
|
|
2306
|
+
}
|
|
2307
|
+
const results = await Promise.all(acquisitions.values());
|
|
2308
|
+
const clients = results.filter((entry) => entry !== undefined);
|
|
2309
|
+
return { clients, serverCountAttempted };
|
|
1799
2310
|
}
|
|
1800
2311
|
/**
|
|
1801
2312
|
* Spawn/get the AUXILIARY clients for a file (role:"auxiliary") restricted to
|
|
1802
2313
|
* the enabled set. These attach alongside the primary on the with-auxiliary
|
|
1803
2314
|
* diagnostics path (cross-cutting scanners like opengrep).
|
|
1804
2315
|
*/
|
|
1805
|
-
async getAuxiliaryClientsForFile(filePath, enabledIds) {
|
|
2316
|
+
async getAuxiliaryClientsForFile(filePath, enabledIds, onOutcome, maxWaitMs, signal, hook) {
|
|
1806
2317
|
if (this.checkDestroyed() || enabledIds.size === 0)
|
|
1807
2318
|
return [];
|
|
1808
2319
|
const servers = getServersForFileWithConfig(filePath).filter((s) => s.role === "auxiliary" && enabledIds.has(s.id));
|
|
1809
2320
|
if (servers.length === 0)
|
|
1810
2321
|
return [];
|
|
1811
|
-
const
|
|
2322
|
+
const rootMemo = new Map();
|
|
2323
|
+
const effectiveSignal = signal ?? getAmbientAbortSignal();
|
|
2324
|
+
const effectiveHook = hook ?? "tool_result_edit";
|
|
2325
|
+
const spawned = await Promise.all(servers.map(async (server) => {
|
|
2326
|
+
const acquisition = this.ensureClientForServer(filePath, server, undefined, undefined, (reported) => onOutcome?.(server.id, reported), rootMemo);
|
|
2327
|
+
if (!maxWaitMs || maxWaitMs <= 0) {
|
|
2328
|
+
return acquisition;
|
|
2329
|
+
}
|
|
2330
|
+
return bounded(acquisition, {
|
|
2331
|
+
ms: maxWaitMs,
|
|
2332
|
+
signal: effectiveSignal,
|
|
2333
|
+
hook: effectiveHook,
|
|
2334
|
+
label: `auxiliary-spawn:${server.id}`,
|
|
2335
|
+
});
|
|
2336
|
+
}));
|
|
1812
2337
|
return spawned.filter((entry) => Boolean(entry));
|
|
1813
2338
|
}
|
|
1814
2339
|
/**
|
|
@@ -1857,11 +2382,11 @@ export class LSPService {
|
|
|
1857
2382
|
return undefined;
|
|
1858
2383
|
}
|
|
1859
2384
|
/**
|
|
1860
|
-
* Read-only
|
|
1861
|
-
*
|
|
1862
|
-
*
|
|
2385
|
+
* Read-only Gate-B readiness check for one auxiliary server/root. A connected
|
|
2386
|
+
* process is not yet a diagnostic producer: only its first publication proves
|
|
2387
|
+
* that this root's client can supersede an in-process fallback runner.
|
|
1863
2388
|
*/
|
|
1864
|
-
async
|
|
2389
|
+
async hasServerPublishedForFileRoot(serverId, filePath) {
|
|
1865
2390
|
if (this.checkDestroyed())
|
|
1866
2391
|
return false;
|
|
1867
2392
|
for (const server of getServersForFileWithConfig(filePath)) {
|
|
@@ -1871,20 +2396,30 @@ export class LSPService {
|
|
|
1871
2396
|
if (!root)
|
|
1872
2397
|
continue;
|
|
1873
2398
|
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
1874
|
-
|
|
2399
|
+
const client = this.state.clients.get(key);
|
|
2400
|
+
// #2324 F1: per-file grain. `diagnosticsVersion` is client-global —
|
|
2401
|
+
// any sibling path's publication bumps it, so it cannot answer "did
|
|
2402
|
+
// THIS server publish for THIS file?" (client.ts:339-341). A file
|
|
2403
|
+
// this server never touched would otherwise read as gated-open the
|
|
2404
|
+
// moment any other file on the same client got a publication.
|
|
2405
|
+
if (client?.isAlive() &&
|
|
2406
|
+
client.getDiagnosticsVersionForPath(filePath) > 0) {
|
|
1875
2407
|
return true;
|
|
2408
|
+
}
|
|
1876
2409
|
}
|
|
1877
2410
|
return false;
|
|
1878
2411
|
}
|
|
1879
2412
|
/**
|
|
1880
2413
|
* #2001/#2002 collect-later: read-only cached-diagnostics probe for the
|
|
1881
2414
|
* turn-end late-auxiliary delivery (`clients/runtime-turn.ts`). Like
|
|
1882
|
-
*
|
|
2415
|
+
* the Gate-B readiness check, this NEVER creates or warms a client — it only
|
|
1883
2416
|
* resolves each requested server's root and reads the already-connected
|
|
1884
2417
|
* client's cached diagnostics for `filePath`. Servers with no live client
|
|
1885
|
-
* are
|
|
1886
|
-
*
|
|
1887
|
-
*
|
|
2418
|
+
* are absent unless notify-stall teardown marked that generation as
|
|
2419
|
+
* replaceable; that status carries the demotion timestamp so turn-end late
|
|
2420
|
+
* coverage can correlate each pair to the removed generation. A live
|
|
2421
|
+
* client is present even when its per-file cache entry is empty; its
|
|
2422
|
+
* timestamp distinguishes a published clean result from no publication.
|
|
1888
2423
|
*/
|
|
1889
2424
|
async readCachedDiagnosticsForServers(filePath, serverIds) {
|
|
1890
2425
|
const out = new Map();
|
|
@@ -1898,9 +2433,25 @@ export class LSPService {
|
|
|
1898
2433
|
continue;
|
|
1899
2434
|
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
1900
2435
|
const client = this.state.clients.get(key);
|
|
1901
|
-
if (!client?.isAlive())
|
|
2436
|
+
if (!client?.isAlive()) {
|
|
2437
|
+
const demotedAt = this.notifyStallDemotions.get(key);
|
|
2438
|
+
if (demotedAt !== undefined) {
|
|
2439
|
+
out.set(server.id, {
|
|
2440
|
+
diags: [],
|
|
2441
|
+
notifyStallDemoted: true,
|
|
2442
|
+
demotedAt,
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
1902
2445
|
continue;
|
|
1903
|
-
|
|
2446
|
+
}
|
|
2447
|
+
// A replacement is live again. The old generation's marker no longer
|
|
2448
|
+
// describes this client and must not make a later absence look transient.
|
|
2449
|
+
this.notifyStallDemotions.delete(key);
|
|
2450
|
+
const entry = client.getAllDiagnostics().get(normalizeMapKey(filePath));
|
|
2451
|
+
out.set(server.id, {
|
|
2452
|
+
diags: entry?.diags ?? [],
|
|
2453
|
+
publishedAt: entry?.ts,
|
|
2454
|
+
});
|
|
1904
2455
|
}
|
|
1905
2456
|
return out;
|
|
1906
2457
|
}
|
|
@@ -2106,7 +2657,29 @@ export class LSPService {
|
|
|
2106
2657
|
noteSpawnVerdict(key, verdict) {
|
|
2107
2658
|
this.lastSpawnVerdict.set(key, verdict);
|
|
2108
2659
|
}
|
|
2109
|
-
|
|
2660
|
+
/**
|
|
2661
|
+
* {@link resolveServerRoot}, memoized against a caller-owned map so one
|
|
2662
|
+
* acquisition asks a given server for its root at most once (#2525).
|
|
2663
|
+
*
|
|
2664
|
+
* Resolution is cheap exactly when it SUCCEEDS: `NearestRoot` caches hits,
|
|
2665
|
+
* but deliberately never caches negatives (so a marker scaffolded
|
|
2666
|
+
* mid-session is picked up without a restart). An unrooted candidate
|
|
2667
|
+
* therefore re-walks to the filesystem root on every call, which is why the
|
|
2668
|
+
* acquisition path and the records it emits must share one resolution
|
|
2669
|
+
* instead of each taking their own. Without a `memo` this is a plain
|
|
2670
|
+
* passthrough, for callers that have no acquisition to share with.
|
|
2671
|
+
*/
|
|
2672
|
+
resolveServerRootOnce(server, filePath, memo) {
|
|
2673
|
+
if (!memo)
|
|
2674
|
+
return this.resolveServerRoot(server, filePath);
|
|
2675
|
+
const pending = memo.get(server.id);
|
|
2676
|
+
if (pending !== undefined)
|
|
2677
|
+
return pending;
|
|
2678
|
+
const started = this.resolveServerRoot(server, filePath);
|
|
2679
|
+
memo.set(server.id, started);
|
|
2680
|
+
return started;
|
|
2681
|
+
}
|
|
2682
|
+
async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight, onOutcome, rootMemo) {
|
|
2110
2683
|
const handoff = this.generationHandoff;
|
|
2111
2684
|
if (handoff) {
|
|
2112
2685
|
await handoff;
|
|
@@ -2116,7 +2689,7 @@ export class LSPService {
|
|
|
2116
2689
|
if (this.checkDestroyed())
|
|
2117
2690
|
return undefined;
|
|
2118
2691
|
}
|
|
2119
|
-
const root = await this.
|
|
2692
|
+
const root = await this.resolveServerRootOnce(server, filePath, rootMemo);
|
|
2120
2693
|
if (!root || this.checkDestroyed())
|
|
2121
2694
|
return undefined;
|
|
2122
2695
|
if (server.role !== "auxiliary") {
|
|
@@ -2219,6 +2792,7 @@ export class LSPService {
|
|
|
2219
2792
|
intentional: wasIntentional,
|
|
2220
2793
|
},
|
|
2221
2794
|
});
|
|
2795
|
+
this.releaseOutstandingAuxNotifyWrite(key);
|
|
2222
2796
|
try {
|
|
2223
2797
|
await existing.shutdown();
|
|
2224
2798
|
}
|
|
@@ -2372,12 +2946,21 @@ export class LSPService {
|
|
|
2372
2946
|
if (isOptionalServer)
|
|
2373
2947
|
this.optionalDisabled.delete(key);
|
|
2374
2948
|
}
|
|
2949
|
+
// #2064: did THIS caller start the language-server process, or did it
|
|
2950
|
+
// join a spawn another caller already had in flight? The answer is only
|
|
2951
|
+
// knowable here, before the await — downstream of `await spawnPromise`
|
|
2952
|
+
// the two are indistinguishable, which is exactly how the 3.0x
|
|
2953
|
+
// over-count happened. There are two join sites and both count as
|
|
2954
|
+
// joins: the unguarded read below, and the `raced` re-read inside the
|
|
2955
|
+
// spawn gate, which catches a caller that reached the gate before the
|
|
2956
|
+
// starter published its promise.
|
|
2957
|
+
let startedSpawn = false;
|
|
2375
2958
|
let spawnPromise = this.state.inFlight.get(key);
|
|
2376
2959
|
if (!spawnPromise) {
|
|
2377
2960
|
const started = await this.withClientSpawnGate(async () => {
|
|
2378
2961
|
const raced = this.state.inFlight.get(key);
|
|
2379
2962
|
if (raced)
|
|
2380
|
-
return { promise: raced };
|
|
2963
|
+
return { promise: raced, startedSpawn: false };
|
|
2381
2964
|
// `server.root()` and dead-client cleanup above are async. A reset during
|
|
2382
2965
|
// either gap must not let this retired generation start a late spawn.
|
|
2383
2966
|
if (this.checkDestroyed())
|
|
@@ -2386,11 +2969,12 @@ export class LSPService {
|
|
|
2386
2969
|
return undefined;
|
|
2387
2970
|
const promise = this.spawnClient(server, root, key, filePath, allowInstall);
|
|
2388
2971
|
this.state.inFlight.set(key, promise);
|
|
2389
|
-
return { promise };
|
|
2972
|
+
return { promise, startedSpawn: true };
|
|
2390
2973
|
});
|
|
2391
2974
|
if (!started)
|
|
2392
2975
|
return undefined;
|
|
2393
2976
|
spawnPromise = started.promise;
|
|
2977
|
+
startedSpawn = started.startedSpawn;
|
|
2394
2978
|
}
|
|
2395
2979
|
// Announce the in-flight spawn so the caller can skip a doomed touch
|
|
2396
2980
|
// wait. The announcement never returns a client and never
|
|
@@ -2410,18 +2994,41 @@ export class LSPService {
|
|
|
2410
2994
|
// #1934: a client here cost a process WAIT, whether this caller
|
|
2411
2995
|
// started the spawn or joined another caller's in-flight promise.
|
|
2412
2996
|
// Either way the selection was not served from the warm pool.
|
|
2997
|
+
// #2064: which of the two it was is now named, so the record can
|
|
2998
|
+
// answer "how many processes started" as well as "how many
|
|
2999
|
+
// selections paid a wait". `startedSpawn` is captured before the
|
|
3000
|
+
// await, because after it the two are indistinguishable.
|
|
2413
3001
|
//
|
|
2414
3002
|
// The verdict read is synchronous and sits in the same microtask as
|
|
2415
3003
|
// the await above, so it can only see the attempt just settled.
|
|
2416
3004
|
onOutcome?.(spawned
|
|
2417
|
-
?
|
|
3005
|
+
? startedSpawn
|
|
3006
|
+
? "cold-spawn"
|
|
3007
|
+
: "cold-spawn-joined"
|
|
2418
3008
|
: this.lastSpawnVerdict.get(key) === "failed"
|
|
2419
|
-
?
|
|
3009
|
+
? startedSpawn
|
|
3010
|
+
? "spawn-failure"
|
|
3011
|
+
: "spawn-failure-joined"
|
|
2420
3012
|
: "declined");
|
|
2421
3013
|
return spawned;
|
|
2422
3014
|
}
|
|
2423
3015
|
catch (err) {
|
|
2424
3016
|
// A throwing spawn promise is an errored acquisition by definition.
|
|
3017
|
+
//
|
|
3018
|
+
// #2064 review F2: deliberately NOT split into a starter/joiner pair
|
|
3019
|
+
// like the resolve path above, because nothing could observe the
|
|
3020
|
+
// split. `spawnClient` never rethrows; it catches its own spawn and
|
|
3021
|
+
// initialize failures and resolves `undefined`. This catch is
|
|
3022
|
+
// therefore reachable only when `spawnClient` throws BEFORE its own
|
|
3023
|
+
// `try` (the trust probe, `logSessionStart`, `recordLsp`). A joiner
|
|
3024
|
+
// CAN read the already-rejected promise out of `inFlight` (the #2106
|
|
3025
|
+
// verify measured 2 invocations for 3 callers), but it does not
|
|
3026
|
+
// matter: the rethrow
|
|
3027
|
+
// below unwinds past every `lsp_client_selected` emit site, so this
|
|
3028
|
+
// value is written to no record: a probe drove a throwing trust
|
|
3029
|
+
// check through two concurrent callers and got two rejections and
|
|
3030
|
+
// zero selection records. An unobservable discriminator is a
|
|
3031
|
+
// vacuous guard, so this path keeps the single #1934 value.
|
|
2425
3032
|
onOutcome?.("spawn-failure");
|
|
2426
3033
|
throw err;
|
|
2427
3034
|
}
|
|
@@ -2514,6 +3121,7 @@ export class LSPService {
|
|
|
2514
3121
|
serverId: server.id,
|
|
2515
3122
|
process: spawned.process,
|
|
2516
3123
|
root,
|
|
3124
|
+
sessionCwd: this.sessionCwd,
|
|
2517
3125
|
initialization: mergedInit,
|
|
2518
3126
|
initializeTimeoutMs: server.initializeTimeoutMs,
|
|
2519
3127
|
launchVariant: spawned.launchVariant,
|
|
@@ -2534,6 +3142,9 @@ export class LSPService {
|
|
|
2534
3142
|
diagnosticProviderKind: "unavailable",
|
|
2535
3143
|
};
|
|
2536
3144
|
this.state.clients.set(key, client);
|
|
3145
|
+
// #2356: this generation is the replacement the late-coverage probe was
|
|
3146
|
+
// waiting for. Clear the retired-generation marker before any later probe.
|
|
3147
|
+
this.notifyStallDemotions.delete(key);
|
|
2537
3148
|
this.unavailableLogged.delete(key);
|
|
2538
3149
|
// #1934 review F1: a success retires the previous verdict, so the map
|
|
2539
3150
|
// never outlives the attempts it describes.
|
|
@@ -2550,6 +3161,36 @@ export class LSPService {
|
|
|
2550
3161
|
const spawnDurationMs = Date.now() - startedAt;
|
|
2551
3162
|
recordLsp(server.id, root, "spawn_success", spawnDurationMs);
|
|
2552
3163
|
recordSuccessfulLspSpawn(server.id, spawnDurationMs);
|
|
3164
|
+
// #2064: the only latency record that a language-server PROCESS
|
|
3165
|
+
// started. `lsp_launch_candidate_success` covers the servers that
|
|
3166
|
+
// launch through `resolveAndLaunch` and never fired for TypeScript,
|
|
3167
|
+
// which served 913 of 941 selections in the field window — so
|
|
3168
|
+
// nothing in `latency.log` counted the 29.3s TypeScript spawn at
|
|
3169
|
+
// all. This sits at `spawnClient`'s single success path, so every
|
|
3170
|
+
// server reports through one record instead of a per-server
|
|
3171
|
+
// launcher, and `count(serverId=typescript)` is answerable from
|
|
3172
|
+
// `latency.log` alone. Volume is bounded by process starts: one
|
|
3173
|
+
// record per spawn, and a spawn is single-flighted per
|
|
3174
|
+
// `serverId:root` by `state.inFlight`.
|
|
3175
|
+
//
|
|
3176
|
+
// The two path fields are deliberate and are not duplicates.
|
|
3177
|
+
// `filePath` carries the ROOT, because the root plus `serverId` is
|
|
3178
|
+
// the client's identity and the unit a spawn is single-flighted on.
|
|
3179
|
+
// `triggerFilePath` carries the file whose touch paid for the
|
|
3180
|
+
// spawn, which answers a different question and is the one that
|
|
3181
|
+
// varies across records for the same client.
|
|
3182
|
+
logLatency({
|
|
3183
|
+
type: "phase",
|
|
3184
|
+
phase: "lsp_server_spawned",
|
|
3185
|
+
filePath: root,
|
|
3186
|
+
durationMs: spawnDurationMs,
|
|
3187
|
+
metadata: {
|
|
3188
|
+
serverId: server.id,
|
|
3189
|
+
source: spawned.source ?? "unknown",
|
|
3190
|
+
launchVariant: spawned.launchVariant ?? "default",
|
|
3191
|
+
triggerFilePath: filePath,
|
|
3192
|
+
},
|
|
3193
|
+
});
|
|
2553
3194
|
if (!this.workspaceProbeLogged.has(key)) {
|
|
2554
3195
|
logSessionStart(`lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`);
|
|
2555
3196
|
this.workspaceProbeLogged.add(key);
|
|
@@ -2646,6 +3287,11 @@ export class LSPService {
|
|
|
2646
3287
|
}
|
|
2647
3288
|
const startedAt = Date.now();
|
|
2648
3289
|
const normalizedPath = normalizeMapKey(filePath);
|
|
3290
|
+
const outsideRoot = await this.findOutsideProjectRoot(filePath);
|
|
3291
|
+
if (outsideRoot) {
|
|
3292
|
+
this.recordOutsideRootDecline(filePath, outsideRoot, "touchFile");
|
|
3293
|
+
return { diags: [], skipReason: "outside-project-root" };
|
|
3294
|
+
}
|
|
2649
3295
|
// #1783: every path that asks a language server anything comes through
|
|
2650
3296
|
// here, so this is where the disk-drift backstop gets its heartbeat.
|
|
2651
3297
|
// Deliberately NOT awaited: the sweep is rate-limited to one pass per
|
|
@@ -2665,6 +3311,12 @@ export class LSPService {
|
|
|
2665
3311
|
const useAllClients = clientScope === "all";
|
|
2666
3312
|
const resolvedPrimaryRoots = new Map();
|
|
2667
3313
|
const waitSkipReasons = new Set();
|
|
3314
|
+
const coldAuxiliaryServerIds = new Set();
|
|
3315
|
+
const noteColdAuxiliary = (serverId, outcome) => {
|
|
3316
|
+
if (outcome === "cold-spawn" || outcome === "cold-spawn-joined") {
|
|
3317
|
+
coldAuxiliaryServerIds.add(serverId);
|
|
3318
|
+
}
|
|
3319
|
+
};
|
|
2668
3320
|
let spawned;
|
|
2669
3321
|
let serverCountAttempted;
|
|
2670
3322
|
if (useAllClients) {
|
|
@@ -2675,10 +3327,27 @@ export class LSPService {
|
|
|
2675
3327
|
else if (clientScope === "with-auxiliary") {
|
|
2676
3328
|
// Primary language server + the enabled cross-cutting auxiliaries
|
|
2677
3329
|
// (opengrep, …). The aggregation layer merges/dedups their diagnostics.
|
|
3330
|
+
const auxStartedAt = Date.now();
|
|
2678
3331
|
const [entry, aux] = await Promise.all([
|
|
2679
3332
|
this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons),
|
|
2680
|
-
this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? [])),
|
|
3333
|
+
this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? []), noteColdAuxiliary, options.maxClientWaitMs, undefined, options.hook),
|
|
2681
3334
|
]);
|
|
3335
|
+
const auxDurationMs = Date.now() - auxStartedAt;
|
|
3336
|
+
if (options.auxiliaryServerIds && options.auxiliaryServerIds.length > 0) {
|
|
3337
|
+
logLatency({
|
|
3338
|
+
type: "phase",
|
|
3339
|
+
phase: "auxiliary_readiness",
|
|
3340
|
+
filePath: normalizedPath,
|
|
3341
|
+
durationMs: auxDurationMs,
|
|
3342
|
+
metadata: {
|
|
3343
|
+
serverIds: aux.map((s) => s.info.id),
|
|
3344
|
+
attemptedCount: options.auxiliaryServerIds.length,
|
|
3345
|
+
readyCount: aux.length,
|
|
3346
|
+
coldServerIds: [...coldAuxiliaryServerIds],
|
|
3347
|
+
source,
|
|
3348
|
+
},
|
|
3349
|
+
});
|
|
3350
|
+
}
|
|
2682
3351
|
spawned = entry ? [entry, ...aux] : aux;
|
|
2683
3352
|
serverCountAttempted = spawned.length;
|
|
2684
3353
|
}
|
|
@@ -2780,6 +3449,10 @@ export class LSPService {
|
|
|
2780
3449
|
// quietly reverting to the global counter, which is the defect itself.
|
|
2781
3450
|
const readPathVersion = (client) => client.getDiagnosticsVersionForPath?.(filePath);
|
|
2782
3451
|
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, readPathVersion(entry.client)]));
|
|
3452
|
+
// #2161: anchor publication evidence before this touch's notify. A
|
|
3453
|
+
// later per-file cache entry, including an empty one, proves that the
|
|
3454
|
+
// primary answered; an empty diagnostics result alone cannot.
|
|
3455
|
+
const markedAtMs = Date.now();
|
|
2783
3456
|
// #1458: read a late auxiliary publication BEFORE the ordinary resync
|
|
2784
3457
|
// clears its client cache. Carry it only when the publication's exact
|
|
2785
3458
|
// sent-content fingerprint matches this touch's content. A changed edit,
|
|
@@ -2879,14 +3552,26 @@ export class LSPService {
|
|
|
2879
3552
|
if (!notifySkipped) {
|
|
2880
3553
|
const budget = notifyWriteBudgetMs();
|
|
2881
3554
|
// #1459: how long a queued auxiliary may wait for its resync slot. Bounded
|
|
2882
|
-
// by the write budget
|
|
2883
|
-
//
|
|
2884
|
-
//
|
|
3555
|
+
// by the write budget and by the effective primary wait floor, minus what
|
|
3556
|
+
// which includes the caller's `maxClientWaitMs` and any primary
|
|
3557
|
+
// server `clientWaitTimeoutMs` override. The elapsed client wait is
|
|
3558
|
+
// subtracted below.
|
|
2885
3559
|
// A flat write-budget wait would tax a caller that asked for less than one
|
|
2886
3560
|
// budget in total. Non-positive means "no time left to queue": the server
|
|
2887
3561
|
// is reported as uncovered immediately.
|
|
3562
|
+
//
|
|
3563
|
+
// #2239: "what the caller already declared" is `primaryServerWaitFloorMs`,
|
|
3564
|
+
// not the raw `options.maxClientWaitMs` — the SAME floor `getClientForFile`
|
|
3565
|
+
// races the primary spawn against. A cold primary configured with its own
|
|
3566
|
+
// `clientWaitTimeoutMs` (Ruby 30s, and the #2233 Bash/JSON/Vue/Svelte/Prisma
|
|
3567
|
+
// overrides) is allowed to run past the caller's flat budget, and did just
|
|
3568
|
+
// that by the time this line runs — charging the subtraction against the
|
|
3569
|
+
// flat value alone always went negative and clamped to zero. The outer
|
|
3570
|
+
// `Math.min(budget, …)` is unchanged, so this raises what "already spent"
|
|
3571
|
+
// is measured against without widening the wait beyond one write budget.
|
|
2888
3572
|
const queueWaitMs = options.maxClientWaitMs !== undefined
|
|
2889
|
-
? Math.min(budget, Math.max(0, options.maxClientWaitMs
|
|
3573
|
+
? Math.min(budget, Math.max(0, primaryServerWaitFloorMs(filePath, options.maxClientWaitMs) -
|
|
3574
|
+
(Date.now() - startedAt)))
|
|
2890
3575
|
: budget;
|
|
2891
3576
|
await Promise.all(spawned.map(async (entry) => {
|
|
2892
3577
|
// #743: this server already has this content from a recent touch
|
|
@@ -2974,10 +3659,13 @@ export class LSPService {
|
|
|
2974
3659
|
// streak outright, so only the late case retracts. A landing
|
|
2975
3660
|
// past the WEDGE window keeps its strike: at that point the
|
|
2976
3661
|
// stall was long enough that #743's demotion is the honest
|
|
2977
|
-
// verdict, not a latency artifact.
|
|
3662
|
+
// verdict, not a latency artifact. #2358: the wedge window
|
|
3663
|
+
// is the adaptive one the timer itself arms with, so a
|
|
3664
|
+
// write a BUSY server would have been left alone for also
|
|
3665
|
+
// retracts its strike instead of accruing a ladder charge.
|
|
2978
3666
|
const outstandingMs = Date.now() - writeStartedAt;
|
|
2979
3667
|
if (outstandingMs > budget &&
|
|
2980
|
-
outstandingMs <=
|
|
3668
|
+
outstandingMs <= this.auxNotifyWedgeBudgetMs(clientKey)) {
|
|
2981
3669
|
this.retractNotifyWriteBackpressure(clientKey, entry.info.id, filePath, outstandingMs, client);
|
|
2982
3670
|
}
|
|
2983
3671
|
}, release);
|
|
@@ -3327,7 +4015,25 @@ export class LSPService {
|
|
|
3327
4015
|
// Fail-open: missing capability state keeps today's push fallback.
|
|
3328
4016
|
}
|
|
3329
4017
|
}
|
|
3330
|
-
const
|
|
4018
|
+
const configuredAuxCeilingMs = readEnvAuxGraceMs();
|
|
4019
|
+
const perServerDeclaredTimeouts = spawned.map((entry) => timeoutFor(entry.client.serverId));
|
|
4020
|
+
const perServerWaitTimeouts = spawned.map((entry, entryIndex) => {
|
|
4021
|
+
const declaredServerTimeout = perServerDeclaredTimeouts[entryIndex];
|
|
4022
|
+
const observedSpawnMs = getSuccessfulLspSpawnDurationMs(entry.client.serverId);
|
|
4023
|
+
return hasTouchAuxiliaries &&
|
|
4024
|
+
entry.info.role === "auxiliary" &&
|
|
4025
|
+
coldAuxiliaryServerIds.has(entry.client.serverId) &&
|
|
4026
|
+
(configuredAuxCeilingMs !== undefined ||
|
|
4027
|
+
(observedSpawnMs !== undefined && observedSpawnMs > 0))
|
|
4028
|
+
? auxWaitBudgetMs(entry.client.serverId, true, configuredAuxCeilingMs, declaredServerTimeout)
|
|
4029
|
+
: perServerDeclaredTimeouts[entryIndex];
|
|
4030
|
+
});
|
|
4031
|
+
const perServerRaceBudgets = spawned.map((entry, entryIndex) => {
|
|
4032
|
+
return hasTouchAuxiliaries && entry.info.role === "auxiliary"
|
|
4033
|
+
? auxWaitBudgetMs(entry.client.serverId, coldAuxiliaryServerIds.has(entry.client.serverId), configuredAuxCeilingMs, perServerDeclaredTimeouts[entryIndex])
|
|
4034
|
+
: perServerDeclaredTimeouts[entryIndex];
|
|
4035
|
+
});
|
|
4036
|
+
const perServerWaits = spawned.map((entry, entryIndex) => {
|
|
3331
4037
|
// #1459: a DEFERRED server never received this content, so its version
|
|
3332
4038
|
// can never advance past the baseline — waiting on it burns its whole
|
|
3333
4039
|
// budget and would flip the touch to `inconclusive`, discarding a
|
|
@@ -3336,7 +4042,7 @@ export class LSPService {
|
|
|
3336
4042
|
if (deferredResyncServerIds.has(entry.info.id)) {
|
|
3337
4043
|
return Promise.resolve(undefined);
|
|
3338
4044
|
}
|
|
3339
|
-
const serverTimeout =
|
|
4045
|
+
const serverTimeout = perServerWaitTimeouts[entryIndex];
|
|
3340
4046
|
// #1531: a per-path baseline. `clientWaitForDiagnostics` compares it
|
|
3341
4047
|
// against this path's own publication stamp, so a sibling file's
|
|
3342
4048
|
// publication on a shared client can no longer end this wait before the
|
|
@@ -3395,14 +4101,13 @@ export class LSPService {
|
|
|
3395
4101
|
serverId: spawned[i].info.id,
|
|
3396
4102
|
client: spawned[i].client,
|
|
3397
4103
|
baseline: diagnosticBaselines.get(spawned[i].client),
|
|
4104
|
+
budgetMs: perServerRaceBudgets[i],
|
|
3398
4105
|
}
|
|
3399
4106
|
: null)
|
|
3400
4107
|
.filter((x) => x !== null);
|
|
3401
|
-
|
|
3402
|
-
//
|
|
3403
|
-
//
|
|
3404
|
-
// 2000ms default admits measured ~1.3s warm scanner runs without
|
|
3405
|
-
// making every edit pay opengrep's 3500ms cold-start allowance.
|
|
4108
|
+
// After all primaries settle, use the same per-auxiliary budget
|
|
4109
|
+
// that bounded its own diagnostic wait. Warm acquisitions retain
|
|
4110
|
+
// the 2000ms ceiling; cold acquisitions include observed startup.
|
|
3406
4111
|
// Late aux results are dropped from this wait. A later unchanged-
|
|
3407
4112
|
// content read may carry a SHA-256-bound cache publication before its
|
|
3408
4113
|
// resync clears the cache; changed or unknown content never replays.
|
|
@@ -3415,7 +4120,7 @@ export class LSPService {
|
|
|
3415
4120
|
return;
|
|
3416
4121
|
const auxWaitStartedAt = Date.now();
|
|
3417
4122
|
const outcomes = await Promise.all(auxWaits.map(async (aux) => {
|
|
3418
|
-
const budgetMs =
|
|
4123
|
+
const { budgetMs } = aux;
|
|
3419
4124
|
let timer;
|
|
3420
4125
|
const timeout = new Promise((resolve) => {
|
|
3421
4126
|
timer = setTimeout(() => resolve(false), budgetMs);
|
|
@@ -3515,7 +4220,24 @@ export class LSPService {
|
|
|
3515
4220
|
// revision and probing it would replay stale findings.
|
|
3516
4221
|
const collectLaterServerIds = outcomes
|
|
3517
4222
|
.filter((o) => !o.publishedThisContent &&
|
|
3518
|
-
(o.outcome === "cut_off" || o.outcome === "silent")
|
|
4223
|
+
(o.outcome === "cut_off" || o.outcome === "silent") &&
|
|
4224
|
+
// #2324 R2-A/R3-A: ast-grep's napi fallback is a
|
|
4225
|
+
// SECOND producer of coverage for this exact pair,
|
|
4226
|
+
// dispatched CONCURRENTLY with this whole touch
|
|
4227
|
+
// (dispatcher.ts's Promise.all groups) — not just
|
|
4228
|
+
// concurrently with this wait. On a COLD touch,
|
|
4229
|
+
// getClientsForFile's spawn+handshake (above, before
|
|
4230
|
+
// waitStartedAt is ever captured) can itself take
|
|
4231
|
+
// long enough that napi's near-instant Gate-B check
|
|
4232
|
+
// records coverage BEFORE waitStartedAt — the
|
|
4233
|
+
// issue's own 68ms race shape. Baseline on startedAt
|
|
4234
|
+
// instead: stamped at touchFile's own entry, before
|
|
4235
|
+
// ANY spawn work, so it is the earliest instant this
|
|
4236
|
+
// touch's own napi run could possibly predate. Still
|
|
4237
|
+
// excludes a stale record from an EARLIER touch,
|
|
4238
|
+
// which is all the staleness guard needs.
|
|
4239
|
+
!(o.serverId === "ast-grep" &&
|
|
4240
|
+
napiFallbackCoveredSince(filePath, startedAt)))
|
|
3519
4241
|
.map((o) => o.serverId);
|
|
3520
4242
|
if (collectLaterServerIds.length > 0) {
|
|
3521
4243
|
markPendingAuxiliaryCoverage(filePath, collectLaterServerIds);
|
|
@@ -3576,8 +4298,11 @@ export class LSPService {
|
|
|
3576
4298
|
// Push already answered (settled, or published diagnostics that
|
|
3577
4299
|
// its wait is about to settle on) — nothing to confirm, no sync
|
|
3578
4300
|
// request goes out.
|
|
4301
|
+
const publishedAt = primaryClient
|
|
4302
|
+
.getAllDiagnostics?.()
|
|
4303
|
+
.get(normalizedPath)?.ts;
|
|
3579
4304
|
if (pushWaitSettled ||
|
|
3580
|
-
|
|
4305
|
+
(publishedAt !== undefined && publishedAt > markedAtMs)) {
|
|
3581
4306
|
return new Promise(() => { });
|
|
3582
4307
|
}
|
|
3583
4308
|
try {
|
|
@@ -4232,9 +4957,7 @@ export class LSPService {
|
|
|
4232
4957
|
reason: reasons.join(", ") || "scanner coverage gap",
|
|
4233
4958
|
});
|
|
4234
4959
|
}
|
|
4235
|
-
|
|
4236
|
-
type: "phase",
|
|
4237
|
-
phase: "lsp_scanner_coverage_gap",
|
|
4960
|
+
emitBounded("lsp_scanner_coverage_gap", `${source}:${normalizedPath}`, {
|
|
4238
4961
|
filePath: normalizedPath,
|
|
4239
4962
|
durationMs: Date.now() - startedAt,
|
|
4240
4963
|
metadata: {
|
|
@@ -4243,10 +4966,10 @@ export class LSPService {
|
|
|
4243
4966
|
...(brokenSkippedServerIds.length > 0 && {
|
|
4244
4967
|
brokenSkippedServerIds,
|
|
4245
4968
|
}),
|
|
4246
|
-
// #1586: the deferrals this touch is actually uncovered for. The
|
|
4969
|
+
// #1586: the deferrals this touch is actually uncovered for. The
|
|
4247
4970
|
// gate action keeps its own record in `lsp_notify_resync_deferred`;
|
|
4248
|
-
// this row exists to prove a blackout, and a scanner already bound
|
|
4249
|
-
// these bytes is not one.
|
|
4971
|
+
// this row exists to prove a blackout, and a scanner already bound
|
|
4972
|
+
// to these bytes is not one.
|
|
4250
4973
|
...(uncoveredDeferredServerIds.length > 0 && {
|
|
4251
4974
|
deferredResyncServerIds: uncoveredDeferredServerIds,
|
|
4252
4975
|
}),
|
|
@@ -4492,6 +5215,49 @@ export class LSPService {
|
|
|
4492
5215
|
this.releaseClientLease(key);
|
|
4493
5216
|
}
|
|
4494
5217
|
}
|
|
5218
|
+
/**
|
|
5219
|
+
* #2052: the file's nearest marker root, but ONLY when the file is outside
|
|
5220
|
+
* every initialized session cwd. Returns undefined (no decline) otherwise.
|
|
5221
|
+
*
|
|
5222
|
+
* The cheap registry test runs FIRST so an in-session file — the hot path —
|
|
5223
|
+
* pays one bounded map walk and never the `server.root()` filesystem walks.
|
|
5224
|
+
*/
|
|
5225
|
+
async findOutsideProjectRoot(filePath) {
|
|
5226
|
+
if (!isOutsideAllSessionRoots(filePath))
|
|
5227
|
+
return undefined;
|
|
5228
|
+
const candidates = await Promise.all(getServersForFileWithConfig(filePath).map((server) => server.root(filePath)));
|
|
5229
|
+
return candidates.find((candidate) => Boolean(candidate));
|
|
5230
|
+
}
|
|
5231
|
+
/**
|
|
5232
|
+
* #2052: one bounded record per normalized foreign root per session.
|
|
5233
|
+
*
|
|
5234
|
+
* `operation` is a PARAMETER, not a constant: `touchFile` and
|
|
5235
|
+
* `getDiagnostics` both decline here, and a record that always claimed
|
|
5236
|
+
* "touchFile" would erase which call site actually refused — the
|
|
5237
|
+
* discriminating identity a bounded record has to preserve. The ledger's
|
|
5238
|
+
* rising edge is keyed on the root, so many files under one foreign root
|
|
5239
|
+
* still collapse to a single detailed row.
|
|
5240
|
+
*/
|
|
5241
|
+
recordOutsideRootDecline(filePath, nearestRoot, operation) {
|
|
5242
|
+
const root = normalizeMapKey(nearestRoot);
|
|
5243
|
+
const sessionRoots = getSessionRootsForTelemetry()
|
|
5244
|
+
.map((sessionRoot) => normalizeMapKey(sessionRoot))
|
|
5245
|
+
.join(", ");
|
|
5246
|
+
emitBounded("lsp_capability_skip", root, {
|
|
5247
|
+
filePath: normalizeMapKey(filePath),
|
|
5248
|
+
durationMs: 0,
|
|
5249
|
+
metadata: {
|
|
5250
|
+
operation,
|
|
5251
|
+
nearestMarkerRoot: root,
|
|
5252
|
+
sessionRoots,
|
|
5253
|
+
identity: root,
|
|
5254
|
+
},
|
|
5255
|
+
}, {
|
|
5256
|
+
ledgerKind: "lsp-capability-skip",
|
|
5257
|
+
risingEdgePer: "identity",
|
|
5258
|
+
reason: `declined LSP root outside session cwd: ${root}`,
|
|
5259
|
+
});
|
|
5260
|
+
}
|
|
4495
5261
|
/**
|
|
4496
5262
|
* Get diagnostics for a file
|
|
4497
5263
|
*/
|
|
@@ -4527,6 +5293,11 @@ export class LSPService {
|
|
|
4527
5293
|
}
|
|
4528
5294
|
async getDiagnostics(filePath, diagnosticsMode = "full") {
|
|
4529
5295
|
const normalizedPath = normalizeMapKey(filePath);
|
|
5296
|
+
const outsideRoot = await this.findOutsideProjectRoot(filePath);
|
|
5297
|
+
if (outsideRoot) {
|
|
5298
|
+
this.recordOutsideRootDecline(filePath, outsideRoot, "getDiagnostics");
|
|
5299
|
+
return [];
|
|
5300
|
+
}
|
|
4530
5301
|
if (this.checkDestroyed()) {
|
|
4531
5302
|
this.lastDiagnosticsHealth.set(normalizedPath, {
|
|
4532
5303
|
health: "destroyed",
|
|
@@ -4972,7 +5743,7 @@ export class LSPService {
|
|
|
4972
5743
|
* `executeCommand` above cannot give a render-path probe:
|
|
4973
5744
|
*
|
|
4974
5745
|
* - **Never spawns.** It resolves the already-connected client map the way
|
|
4975
|
-
*
|
|
5746
|
+
* other read-only client-map lookups do, instead of routing through
|
|
4976
5747
|
* `getClientForFile` → `ensureClientForServer`. A probe that spawns a
|
|
4977
5748
|
* language-server fleet to answer a question about how to RENDER a
|
|
4978
5749
|
* diagnostic is a cost the caller never asked for — and under warm attach
|
|
@@ -5180,7 +5951,21 @@ export class LSPService {
|
|
|
5180
5951
|
const activeClients = this.activeClientsForCwd(cwd, priorityServerIds);
|
|
5181
5952
|
const willRenameFailures = [];
|
|
5182
5953
|
const didRenameFailures = [];
|
|
5183
|
-
const
|
|
5954
|
+
const willRenameClients = activeClients.filter(({ serverId, client }) => {
|
|
5955
|
+
if (client.getOperationSupport().willRenameFiles === true)
|
|
5956
|
+
return true;
|
|
5957
|
+
recordDegradationOnce({
|
|
5958
|
+
kind: "lsp-capability-skip",
|
|
5959
|
+
subject: `${serverId}:workspace/willRenameFiles`,
|
|
5960
|
+
reason: client
|
|
5961
|
+
.getMalformedFileOperationRegistrations()
|
|
5962
|
+
.has("willRename")
|
|
5963
|
+
? "malformed-registration"
|
|
5964
|
+
: "no-registration",
|
|
5965
|
+
});
|
|
5966
|
+
return false;
|
|
5967
|
+
});
|
|
5968
|
+
const willResults = await Promise.all(willRenameClients.map(async ({ serverId, client }) => {
|
|
5184
5969
|
try {
|
|
5185
5970
|
return {
|
|
5186
5971
|
serverId,
|
|
@@ -5196,7 +5981,7 @@ export class LSPService {
|
|
|
5196
5981
|
}
|
|
5197
5982
|
}));
|
|
5198
5983
|
const successfulWillResults = willResults.filter((result) => !willRenameFailures.some((failure) => failure.serverId === result.serverId));
|
|
5199
|
-
if (
|
|
5984
|
+
if (willRenameClients.length > 0 && successfulWillResults.length === 0) {
|
|
5200
5985
|
throw new Error(`workspace/willRenameFiles failed for all active LSP servers: ${willRenameFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
|
|
5201
5986
|
}
|
|
5202
5987
|
const merged = mergeWorkspaceTextEditsByPriority(successfulWillResults);
|
|
@@ -5343,6 +6128,21 @@ export class LSPService {
|
|
|
5343
6128
|
path.basename(newFilePath);
|
|
5344
6129
|
const renameDescription = `Renamed ${relOld} → ${relNew}`;
|
|
5345
6130
|
await Promise.all(activeClients.map(async ({ serverId, client }) => {
|
|
6131
|
+
// #1971 review: didRename has its own registration. A server that
|
|
6132
|
+
// never asked for didRenameFiles notifications must not receive
|
|
6133
|
+
// them — same chokepoint discipline as the willRename preflight.
|
|
6134
|
+
if (client.getOperationSupport().didRenameFiles !== true) {
|
|
6135
|
+
recordDegradationOnce({
|
|
6136
|
+
kind: "lsp-capability-skip",
|
|
6137
|
+
subject: `${serverId}:workspace/didRenameFiles`,
|
|
6138
|
+
reason: client
|
|
6139
|
+
.getMalformedFileOperationRegistrations()
|
|
6140
|
+
.has("didRename")
|
|
6141
|
+
? "malformed-registration"
|
|
6142
|
+
: "no-registration",
|
|
6143
|
+
});
|
|
6144
|
+
return;
|
|
6145
|
+
}
|
|
5346
6146
|
const opened = openDocuments.find((entry) => entry.serverId === serverId);
|
|
5347
6147
|
// #1621: bounded for the same reason as the didClose notify above —
|
|
5348
6148
|
// rename propagation is best-effort advice to servers, not a
|
|
@@ -5598,6 +6398,23 @@ export class LSPService {
|
|
|
5598
6398
|
// never proved it can answer diagnostics — that's a failed warm-up. A
|
|
5599
6399
|
// server with an unresolvable key never spawns here, so it can't fail this
|
|
5600
6400
|
// way and is excluded (never skipped by the caller for this file).
|
|
6401
|
+
// #2525: same split the readiness logic above already makes — `keys[i]`
|
|
6402
|
+
// is `undefined` exactly when the server resolves no key for this file,
|
|
6403
|
+
// i.e. it will never spawn here (DenoServer without a deno.json). The
|
|
6404
|
+
// `lsp_sweep_warmup_*` records must not claim to have warmed it. Free:
|
|
6405
|
+
// `keys` is already resolved, so this costs no filesystem work.
|
|
6406
|
+
const rootedServerIds = [];
|
|
6407
|
+
const unrootedServerIds = [];
|
|
6408
|
+
for (let i = 0; i < servers.length; i++) {
|
|
6409
|
+
if (keys[i] !== undefined)
|
|
6410
|
+
rootedServerIds.push(servers[i].id);
|
|
6411
|
+
else
|
|
6412
|
+
unrootedServerIds.push(servers[i].id);
|
|
6413
|
+
}
|
|
6414
|
+
const unrootedCandidates = {
|
|
6415
|
+
count: unrootedServerIds.length,
|
|
6416
|
+
ids: unrootedServerIds,
|
|
6417
|
+
};
|
|
5601
6418
|
const stillColdServerIds = () => {
|
|
5602
6419
|
const cold = [];
|
|
5603
6420
|
for (let i = 0; i < servers.length; i++) {
|
|
@@ -5617,7 +6434,12 @@ export class LSPService {
|
|
|
5617
6434
|
phase: "lsp_sweep_warmup_start",
|
|
5618
6435
|
filePath: representativeFile,
|
|
5619
6436
|
durationMs: 0,
|
|
5620
|
-
metadata: {
|
|
6437
|
+
metadata: {
|
|
6438
|
+
serverIds: rootedServerIds,
|
|
6439
|
+
unrootedCandidates,
|
|
6440
|
+
timeoutMs,
|
|
6441
|
+
attempt,
|
|
6442
|
+
},
|
|
5621
6443
|
});
|
|
5622
6444
|
const warmupAttempt = this.touchFile(representativeFile, content, {
|
|
5623
6445
|
diagnostics: "document",
|
|
@@ -5667,7 +6489,8 @@ export class LSPService {
|
|
|
5667
6489
|
filePath: representativeFile,
|
|
5668
6490
|
durationMs: Date.now() - startedAt,
|
|
5669
6491
|
metadata: {
|
|
5670
|
-
serverIds:
|
|
6492
|
+
serverIds: rootedServerIds,
|
|
6493
|
+
unrootedCandidates,
|
|
5671
6494
|
timeoutMs,
|
|
5672
6495
|
attempt,
|
|
5673
6496
|
coldServerIds,
|
|
@@ -5828,6 +6651,10 @@ export class LSPService {
|
|
|
5828
6651
|
// mtime it was ACTUALLY scanned at (not re-stat'd after the fact, which
|
|
5829
6652
|
// could race a concurrent edit).
|
|
5830
6653
|
const scannedMtimeByFile = new Map();
|
|
6654
|
+
// #2300: size from the SAME stat call as the mtime above — zero extra
|
|
6655
|
+
// syscalls — so the cache write below can give `isEntryFresh` a size
|
|
6656
|
+
// axis alongside the mtime.
|
|
6657
|
+
const scannedSizeByFile = new Map();
|
|
5831
6658
|
// Group files by their primary language server (#387, extracted as
|
|
5832
6659
|
// `groupFilesByPrimaryServer` for #631). tsserver — and most servers — is
|
|
5833
6660
|
// single-threaded per project: N concurrent touches to ONE server don't
|
|
@@ -6018,7 +6845,9 @@ export class LSPService {
|
|
|
6018
6845
|
// microseconds with no extra event-loop tick, where an awaited
|
|
6019
6846
|
// promise would insert one.
|
|
6020
6847
|
try {
|
|
6021
|
-
|
|
6848
|
+
const scanStat = nodeFs.statSync(filePath);
|
|
6849
|
+
scannedMtimeByFile.set(filePath, scanStat.mtimeMs);
|
|
6850
|
+
scannedSizeByFile.set(filePath, scanStat.size);
|
|
6022
6851
|
}
|
|
6023
6852
|
catch {
|
|
6024
6853
|
// Best-effort: a failed stat here just means this file won't be
|
|
@@ -6091,20 +6920,35 @@ export class LSPService {
|
|
|
6091
6920
|
// instead of aggregating as a confirmed clean. Every route is gated here.
|
|
6092
6921
|
const unconfirmedServerIds = touchCoverageGap(touchResult);
|
|
6093
6922
|
const coverageGap = unconfirmedServerIds.length > 0;
|
|
6923
|
+
// #2052: the touch DECLINED this file — it is outside every
|
|
6924
|
+
// initialized session cwd, so nothing was asked and `diags` is an
|
|
6925
|
+
// empty placeholder, not an observation. Before this, the sweep
|
|
6926
|
+
// never read `skipReason`, so a declined foreign file fell through
|
|
6927
|
+
// as `timedOut: false` with zero diagnostics: reported CONFIRMED
|
|
6928
|
+
// CLEAN, and then written into the workspace cache by the record
|
|
6929
|
+
// loop below, which replays that false clean on every later sweep.
|
|
6930
|
+
const declinedOutsideRoot = touchResult?.skipReason === "outside-project-root";
|
|
6094
6931
|
// #1549: the sweep verdict is per answering lane. An auxiliary gap
|
|
6095
6932
|
// narrows coverage, but a primary answer remains usable; only absence of
|
|
6096
6933
|
// the touch result or a primary-scoped inconclusive verdict poisons it.
|
|
6097
|
-
const timedOut = touchResult === undefined || inconclusive;
|
|
6934
|
+
const timedOut = touchResult === undefined || inconclusive || declinedOutsideRoot;
|
|
6098
6935
|
if (timedOut)
|
|
6099
6936
|
timedOutFiles += 1;
|
|
6100
6937
|
// #1618: WHY, in priority order — the outer deadline (nothing came
|
|
6101
6938
|
// back at all) outranks an inner inconclusive signal, which outranks
|
|
6102
6939
|
// a narrower auxiliary coverage gap.
|
|
6940
|
+
// #2052: `outside_project_root` is checked FIRST among the reasons a
|
|
6941
|
+
// touch produced no observation. A declined file cannot be
|
|
6942
|
+
// `inconclusive` (nothing was asked, so nothing timed out), and
|
|
6943
|
+
// rendering it as "didn't complete within budget" would tell the
|
|
6944
|
+
// reader to retry a request that will always be refused.
|
|
6103
6945
|
const unconfirmedReason = touchResult === undefined
|
|
6104
6946
|
? "budget"
|
|
6105
|
-
:
|
|
6106
|
-
? "
|
|
6107
|
-
:
|
|
6947
|
+
: declinedOutsideRoot
|
|
6948
|
+
? "outside_project_root"
|
|
6949
|
+
: inconclusive
|
|
6950
|
+
? "inconclusive"
|
|
6951
|
+
: undefined;
|
|
6108
6952
|
// #1104 (shape 5 — AGENTS.md): the touch's content binding is an
|
|
6109
6953
|
// EXPLICIT enumerable field on the wrapper now (#1179), so it survives
|
|
6110
6954
|
// `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
|
|
@@ -6187,140 +7031,177 @@ export class LSPService {
|
|
|
6187
7031
|
// is the same primitive `tools/lsp-diagnostics.ts`'s batch/directory scan
|
|
6188
7032
|
// now uses for its own file list.
|
|
6189
7033
|
const groupWorkers = Math.min(WORKSPACE_DIAGNOSTICS_CONCURRENCY, groups.length);
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
7034
|
+
// #1723 residual, named as a deferred gap on PR #1805 and closed here:
|
|
7035
|
+
// the SCAN-side twin of the runner chokepoint #1805 wired in
|
|
7036
|
+
// `dispatcher.ts`'s `runRunner`. This sweep is the site whose COMPLETION
|
|
7037
|
+
// (`lsp_workspace_diagnostics`) is the `lastPhase` on #1723's own
|
|
7038
|
+
// 18 270 ms reproduction, so until this bracket existed the record could
|
|
7039
|
+
// only ever name the phase that finished BEFORE the block, never the one
|
|
7040
|
+
// burning the time during it.
|
|
7041
|
+
//
|
|
7042
|
+
// ONE bracket for the whole fan-out, not one per file (#2272 review F1).
|
|
7043
|
+
// Per-file bracketing was the first shape and it was inert for exactly
|
|
7044
|
+
// the case above: `phaseFinished` pushes onto a ring capped at
|
|
7045
|
+
// `CLOSED_BRACKET_CAP` (5, clients/latency-logger.ts), while `turn_end`
|
|
7046
|
+
// reads `getPhaseForWindow` AFTER the sweep returns. A 225-file sweep
|
|
7047
|
+
// therefore evicted the blocking file's own bracket unless the block
|
|
7048
|
+
// landed in the last five files, and the survivors were then rejected by
|
|
7049
|
+
// `MIN_PLAUSIBLE_ELAPSED_FRACTION` for being far shorter than the block
|
|
7050
|
+
// window. The phase string is a constant carrying no per-file identity,
|
|
7051
|
+
// so 225 brackets never held more information than one — they only cost
|
|
7052
|
+
// 225 map inserts and destroyed the ring. One bracket keeps the same
|
|
7053
|
+
// discriminating identity, survives to the read point, and spans the
|
|
7054
|
+
// block by construction.
|
|
7055
|
+
//
|
|
7056
|
+
// Paired in `finally`, per `phaseFinished`'s contract: an abandoned
|
|
7057
|
+
// bracket would misattribute every LATER loop_block to this sweep. The
|
|
7058
|
+
// leak guard is what the tests pin: emptying this `finally` reds three
|
|
7059
|
+
// of them. `finally` rather than a trailing call covers a throw that
|
|
7060
|
+
// escapes the fan-out; abort and service-destroyed both RETURN from the
|
|
7061
|
+
// worker callback rather than throwing, so those two paths alone would
|
|
7062
|
+
// not distinguish the two spellings.
|
|
7063
|
+
const sweepPhase = phaseStarted("lsp_workspace_diagnostics_touch");
|
|
7064
|
+
try {
|
|
7065
|
+
await runPerServerGroups(groups, groupWorkers, async (group) => {
|
|
7066
|
+
if (signal?.aborted)
|
|
7067
|
+
return;
|
|
7068
|
+
// #1618: checked before any per-group work (pull attempt, warm-up,
|
|
7069
|
+
// per-file loop) so a service already destroyed when this group
|
|
7070
|
+
// starts never pays for a language-server round trip it cannot get.
|
|
7071
|
+
if (this.checkDestroyed()) {
|
|
7072
|
+
markServiceDestroyed(group.files);
|
|
7073
|
+
return;
|
|
7074
|
+
}
|
|
7075
|
+
// Fast path: one project-wide pull for the whole group (opt-in).
|
|
7076
|
+
if (!isWarmAttached() && workspacePullEnabled && !group.multiServer) {
|
|
7077
|
+
const pulled = await this.tryWorkspacePull(group.files, perFileMs, cacheServedKeys);
|
|
7078
|
+
if (pulled) {
|
|
7079
|
+
// #1782: an explicit zero-diagnostic answer for a file this
|
|
7080
|
+
// sweep served from cache SUPERSEDES that cached replay. Route
|
|
7081
|
+
// it through the same result list every other answer uses, so
|
|
7082
|
+
// the cache write below overwrites the stale entry and the
|
|
7083
|
+
// footer reconcile in `tools/lens-diagnostics.ts` clears the
|
|
7084
|
+
// widget rows — no second eviction path to keep in step.
|
|
7085
|
+
for (const clean of pulled.extraClean) {
|
|
7086
|
+
supersededCacheKeys.add(normalizeMapKey(clean.filePath));
|
|
6224
7087
|
}
|
|
6225
|
-
|
|
6226
|
-
|
|
7088
|
+
for (const result of [...pulled.results, ...pulled.extraClean]) {
|
|
7089
|
+
results.push({
|
|
7090
|
+
...result,
|
|
7091
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
|
|
7092
|
+
});
|
|
7093
|
+
// #671: a pull result is always confirmed (see
|
|
7094
|
+
// `tryWorkspacePull`'s doc comment), so it's cache-eligible
|
|
7095
|
+
// too — best-effort stat since the pull already resolved the
|
|
7096
|
+
// diagnostics for this file some time ago.
|
|
7097
|
+
try {
|
|
7098
|
+
const pullStat = nodeFs.statSync(result.filePath);
|
|
7099
|
+
scannedMtimeByFile.set(result.filePath, pullStat.mtimeMs);
|
|
7100
|
+
scannedSizeByFile.set(result.filePath, pullStat.size);
|
|
7101
|
+
}
|
|
7102
|
+
catch {
|
|
7103
|
+
// Not cache-eligible without a confirmed mtime.
|
|
7104
|
+
}
|
|
6227
7105
|
}
|
|
7106
|
+
completed += group.files.length;
|
|
7107
|
+
options.onProgress?.(completed, files.length);
|
|
7108
|
+
return;
|
|
6228
7109
|
}
|
|
6229
|
-
completed += group.files.length;
|
|
6230
|
-
options.onProgress?.(completed, files.length);
|
|
6231
|
-
return;
|
|
6232
7110
|
}
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
},
|
|
6271
|
-
});
|
|
6272
|
-
for (const filePath of group.files) {
|
|
6273
|
-
results.push({
|
|
6274
|
-
filePath,
|
|
6275
|
-
diagnostics: [],
|
|
6276
|
-
count: 0,
|
|
6277
|
-
timedOut: true,
|
|
6278
|
-
unconfirmedReason: "inconclusive",
|
|
6279
|
-
skippedWarmupFailure: true,
|
|
7111
|
+
// #667: warm-check before this group's own per-file loop starts —
|
|
7112
|
+
// cheap/no-op when the group's primary server already demonstrated
|
|
7113
|
+
// readiness (from an earlier sweep, or an earlier group sharing the
|
|
7114
|
+
// same server root, this session); pays one deliberate warm-up round
|
|
7115
|
+
// trip against the group's first file only when genuinely cold. Not
|
|
7116
|
+
// needed above the pull fast path: a `workspace/diagnostic` pull
|
|
7117
|
+
// already covers the WHOLE group with its own generous per-server
|
|
7118
|
+
// budget in one shot — the per-file "first N files eat individual
|
|
7119
|
+
// timeouts" failure mode this fixes doesn't apply there.
|
|
7120
|
+
const first = group.files[0];
|
|
7121
|
+
if (first && !isWarmAttached()) {
|
|
7122
|
+
const warmup = await this.ensureWarmForSweep(first, { signal });
|
|
7123
|
+
if (signal?.aborted)
|
|
7124
|
+
return;
|
|
7125
|
+
// #744: the group's primary server failed warm-up (initial round
|
|
7126
|
+
// trip + one retry both left it cold). Every per-file touch to it
|
|
7127
|
+
// would re-pay its full timeout and time out again, dragging the
|
|
7128
|
+
// whole sweep — the exact wedged-marksman failure mode this closes.
|
|
7129
|
+
// So skip this group's files and record each as UNCONFIRMED
|
|
7130
|
+
// (timedOut + skippedWarmupFailure), never as confirmed-clean `[]`:
|
|
7131
|
+
// the group is keyed by its primary server, so a non-empty
|
|
7132
|
+
// `failedServerIds` means that primary is the one that couldn't warm.
|
|
7133
|
+
if (warmup.failedServerIds.length > 0) {
|
|
7134
|
+
logLatency({
|
|
7135
|
+
type: "phase",
|
|
7136
|
+
phase: "lsp_sweep_group_skipped_warmup",
|
|
7137
|
+
filePath: first,
|
|
7138
|
+
durationMs: 0,
|
|
7139
|
+
metadata: {
|
|
7140
|
+
failedServerIds: warmup.failedServerIds,
|
|
7141
|
+
// #799: distinguishes a fresh warm-up failure from a
|
|
7142
|
+
// negative-cache hit (this sweep never re-attempted warm-up
|
|
7143
|
+
// at all — it was already known cold from earlier this
|
|
7144
|
+
// session).
|
|
7145
|
+
skippedFromCache: warmup.skippedFromCache ?? false,
|
|
7146
|
+
skippedFiles: group.files.length,
|
|
7147
|
+
},
|
|
6280
7148
|
});
|
|
6281
|
-
|
|
6282
|
-
|
|
7149
|
+
for (const filePath of group.files) {
|
|
7150
|
+
results.push({
|
|
7151
|
+
filePath,
|
|
7152
|
+
diagnostics: [],
|
|
7153
|
+
count: 0,
|
|
7154
|
+
timedOut: true,
|
|
7155
|
+
unconfirmedReason: "inconclusive",
|
|
7156
|
+
skippedWarmupFailure: true,
|
|
7157
|
+
});
|
|
7158
|
+
timedOutFiles += 1;
|
|
7159
|
+
completed += 1;
|
|
7160
|
+
}
|
|
7161
|
+
options.onProgress?.(completed, files.length);
|
|
7162
|
+
return;
|
|
6283
7163
|
}
|
|
6284
|
-
|
|
6285
|
-
|
|
7164
|
+
if (warmup.performedWarmup)
|
|
7165
|
+
options.onServerReady?.();
|
|
6286
7166
|
}
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
// chunk's opens still land inside the debounce window and
|
|
6296
|
-
// coalesce into one flush — see `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE`.
|
|
6297
|
-
for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
|
|
6298
|
-
if (signal?.aborted)
|
|
6299
|
-
return;
|
|
6300
|
-
// #1618: a service destroyed WHILE this group's chunk loop was
|
|
6301
|
-
// already running (the group-start check above can't see a
|
|
6302
|
-
// destruction that lands mid-loop) — stop here and mark every
|
|
6303
|
-
// file from this chunk onward, rather than letting the remaining
|
|
6304
|
-
// chunks pay for pre-opens/touches against a torn-down service.
|
|
6305
|
-
if (this.checkDestroyed()) {
|
|
6306
|
-
markServiceDestroyed(group.files.slice(chunkStart));
|
|
6307
|
-
return;
|
|
6308
|
-
}
|
|
6309
|
-
const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
|
|
6310
|
-
await preOpenGroupFiles(chunk);
|
|
6311
|
-
for (const filePath of chunk) {
|
|
6312
|
-
// Honor cancellation between files (#341); already-collected
|
|
6313
|
-
// results are returned as a partial.
|
|
7167
|
+
// #608/#621: batch-open a CHUNK of this group's files before
|
|
7168
|
+
// waiting on diagnostics for any of them individually — see
|
|
7169
|
+
// `preOpenGroupFiles` above. Chunking (rather than the whole
|
|
7170
|
+
// group at once) bounds how much a single burst can dump on
|
|
7171
|
+
// the server's request queue at real project scale, while each
|
|
7172
|
+
// chunk's opens still land inside the debounce window and
|
|
7173
|
+
// coalesce into one flush — see `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE`.
|
|
7174
|
+
for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
|
|
6314
7175
|
if (signal?.aborted)
|
|
6315
7176
|
return;
|
|
7177
|
+
// #1618: a service destroyed WHILE this group's chunk loop was
|
|
7178
|
+
// already running (the group-start check above can't see a
|
|
7179
|
+
// destruction that lands mid-loop) — stop here and mark every
|
|
7180
|
+
// file from this chunk onward, rather than letting the remaining
|
|
7181
|
+
// chunks pay for pre-opens/touches against a torn-down service.
|
|
6316
7182
|
if (this.checkDestroyed()) {
|
|
6317
|
-
markServiceDestroyed(group.files.slice(
|
|
7183
|
+
markServiceDestroyed(group.files.slice(chunkStart));
|
|
6318
7184
|
return;
|
|
6319
7185
|
}
|
|
6320
|
-
|
|
7186
|
+
const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
|
|
7187
|
+
await preOpenGroupFiles(chunk);
|
|
7188
|
+
for (const filePath of chunk) {
|
|
7189
|
+
// Honor cancellation between files (#341); already-collected
|
|
7190
|
+
// results are returned as a partial.
|
|
7191
|
+
if (signal?.aborted)
|
|
7192
|
+
return;
|
|
7193
|
+
if (this.checkDestroyed()) {
|
|
7194
|
+
markServiceDestroyed(group.files.slice(group.files.indexOf(filePath)));
|
|
7195
|
+
return;
|
|
7196
|
+
}
|
|
7197
|
+
await processFile(filePath);
|
|
7198
|
+
}
|
|
6321
7199
|
}
|
|
6322
|
-
}
|
|
6323
|
-
}
|
|
7200
|
+
}, signal);
|
|
7201
|
+
}
|
|
7202
|
+
finally {
|
|
7203
|
+
phaseFinished(sweepPhase);
|
|
7204
|
+
}
|
|
6324
7205
|
// #1618: per-reason tally alongside the flat `timedOutFiles` count (kept
|
|
6325
7206
|
// for the existing `scripts/analyze-pi-lens-logs.mjs` consumer) — a
|
|
6326
7207
|
// dashboard reading only the flat count can no longer mistake 81
|
|
@@ -6386,7 +7267,7 @@ export class LSPService {
|
|
|
6386
7267
|
// boundToCurrentDisk !== false`) could never demote a pull-cached
|
|
6387
7268
|
// entry. Absent `contentHash` (no binding was available) behaves
|
|
6388
7269
|
// exactly as before.
|
|
6389
|
-
workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
|
|
7270
|
+
workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash, scannedSizeByFile.get(result.filePath));
|
|
6390
7271
|
}
|
|
6391
7272
|
workspaceDiagnosticsCacheCtx.persist();
|
|
6392
7273
|
// #1782: drop every cached replay a pull answered clean — returning both
|
|
@@ -6634,6 +7515,9 @@ export class LSPService {
|
|
|
6634
7515
|
if (this.checkDestroyed())
|
|
6635
7516
|
return;
|
|
6636
7517
|
this.isDestroyed = true;
|
|
7518
|
+
for (const [key, token] of this.outstandingAuxNotifyWrites) {
|
|
7519
|
+
this.releaseOutstandingAuxNotifyWrite(key, token);
|
|
7520
|
+
}
|
|
6637
7521
|
for (const key of this.typeScriptIdleTimers.keys()) {
|
|
6638
7522
|
this.clearTypeScriptIdleTimer(key);
|
|
6639
7523
|
}
|
|
@@ -6686,7 +7570,15 @@ export class LSPService {
|
|
|
6686
7570
|
// #1459: every gated client is gone, so no outstanding-write record can
|
|
6687
7571
|
// describe a live one. The gate's identity check already neutralises a stale
|
|
6688
7572
|
// entry; clearing keeps the map honest rather than relying on that.
|
|
6689
|
-
this.outstandingAuxNotifyWrites
|
|
7573
|
+
for (const [key, token] of this.outstandingAuxNotifyWrites) {
|
|
7574
|
+
this.releaseOutstandingAuxNotifyWrite(key, token);
|
|
7575
|
+
}
|
|
7576
|
+
this.notifyStallDemotions.clear();
|
|
7577
|
+
// #2358: same reasoning — a per-write latency estimate belongs to a client
|
|
7578
|
+
// generation, and every client is gone. `session_start` reaches this
|
|
7579
|
+
// through the service reset, so the adaptive wedge window re-arms with
|
|
7580
|
+
// the session rather than being priced by a previous one's throughput.
|
|
7581
|
+
this.auxNotifyDrainLatencyEwma.clear();
|
|
6690
7582
|
// #1714: same reasoning — a backlog count belongs to a client generation,
|
|
6691
7583
|
// and every client is gone. `session_start` reaches this through the service
|
|
6692
7584
|
// reset, so the pacing state re-arms with the session rather than living for
|
|
@@ -6831,27 +7723,52 @@ export class LSPService {
|
|
|
6831
7723
|
return [...primary, ...aux];
|
|
6832
7724
|
}
|
|
6833
7725
|
}
|
|
6834
|
-
|
|
6835
|
-
|
|
7726
|
+
const LSP_PROCESS_FAMILY = "lsp.service";
|
|
7727
|
+
const LSP_PROCESS_VERSION = 1;
|
|
7728
|
+
function lspProcessState() {
|
|
7729
|
+
let incompatibleHandoff;
|
|
7730
|
+
return getProcessSingleton(LSP_PROCESS_FAMILY, LSP_PROCESS_VERSION, () => ({ service: null, generationHandoff: incompatibleHandoff }), (value) => {
|
|
7731
|
+
// Shut down a live incompatible service before replacing its cell.
|
|
7732
|
+
if (!value || typeof value !== "object")
|
|
7733
|
+
return;
|
|
7734
|
+
const candidate = value;
|
|
7735
|
+
const previousHandoff = candidate.generationHandoff;
|
|
7736
|
+
const teardown = candidate.service?.shutdown
|
|
7737
|
+
? candidate.service
|
|
7738
|
+
.shutdown({ fast: true, reason: "process_singleton_reset" })
|
|
7739
|
+
.catch(() => undefined)
|
|
7740
|
+
: undefined;
|
|
7741
|
+
if (previousHandoff && teardown) {
|
|
7742
|
+
incompatibleHandoff = Promise.allSettled([
|
|
7743
|
+
previousHandoff,
|
|
7744
|
+
teardown,
|
|
7745
|
+
]).then(() => undefined);
|
|
7746
|
+
}
|
|
7747
|
+
else {
|
|
7748
|
+
incompatibleHandoff = previousHandoff ?? teardown;
|
|
7749
|
+
}
|
|
7750
|
+
});
|
|
7751
|
+
}
|
|
7752
|
+
function processService() {
|
|
7753
|
+
const state = lspProcessState();
|
|
7754
|
+
if (!state.service)
|
|
7755
|
+
state.service = new LSPService(state.generationHandoff, process.cwd());
|
|
7756
|
+
return state.service;
|
|
7757
|
+
}
|
|
6836
7758
|
/**
|
|
6837
7759
|
* #850: all singleton generations whose teardown is still pending. A new
|
|
6838
7760
|
* generation may be allocated synchronously, but its first spawn waits on this
|
|
6839
7761
|
* handoff so two generations can never own the same server/root concurrently.
|
|
6840
7762
|
*/
|
|
6841
|
-
let globalLSPGenerationHandoff;
|
|
6842
7763
|
export function getLSPService() {
|
|
6843
|
-
|
|
6844
|
-
globalLSPService = new LSPService(globalLSPGenerationHandoff);
|
|
6845
|
-
}
|
|
6846
|
-
return globalLSPService;
|
|
7764
|
+
return processService();
|
|
6847
7765
|
}
|
|
6848
7766
|
/**
|
|
6849
|
-
*
|
|
6850
|
-
*
|
|
6851
|
-
* binary.
|
|
7767
|
+
* Gate-B readiness seam. It reads only the live client map and never spawns,
|
|
7768
|
+
* waits for initialization, or probes a binary.
|
|
6852
7769
|
*/
|
|
6853
|
-
export async function
|
|
6854
|
-
return getLSPService().
|
|
7770
|
+
export async function hasAuxiliaryLspPublishedForRoot(serverId, filePath) {
|
|
7771
|
+
return getLSPService().hasServerPublishedForFileRoot(serverId, filePath);
|
|
6855
7772
|
}
|
|
6856
7773
|
/**
|
|
6857
7774
|
* Cross-layer seam (#1668) for callers outside `lsp/` (bash/write tool-result
|
|
@@ -6863,6 +7780,20 @@ export async function notifyExternalFileChange(filePath, type) {
|
|
|
6863
7780
|
return getLSPService().notifyExternalFileChange(filePath, type);
|
|
6864
7781
|
}
|
|
6865
7782
|
export function resetLSPService(options = {}) {
|
|
7783
|
+
// #2504 review round 2 (F3): whatever the retiring service was still being
|
|
7784
|
+
// asked for off-hook must stop HERE, before any teardown and before the
|
|
7785
|
+
// `!retiringService` early return below (a `session_start` reset with no
|
|
7786
|
+
// live service must still retire a loop the previous session armed). This
|
|
7787
|
+
// is the one choke point every lifecycle path goes through —
|
|
7788
|
+
// `session_shutdown`, `session_start`, and the idle reset — so the
|
|
7789
|
+
// deferred actionable-warnings pull cannot open another file against a
|
|
7790
|
+
// dying service, cannot re-spawn one after the idle reset, and cannot be
|
|
7791
|
+
// mid-`openFile` when the loop closes (#234). Aborting spawns nothing.
|
|
7792
|
+
abortDeferredLspWork(`lsp service reset: ${options.reason ?? "unspecified"}`);
|
|
7793
|
+
// Invalidate availability publication started by the retiring service before
|
|
7794
|
+
// any asynchronous teardown. The launch seam checks this generation after
|
|
7795
|
+
// every managed lookup, install, and process launch (#2351).
|
|
7796
|
+
resetLspLaunchAvailabilityGeneration();
|
|
6866
7797
|
// A new session must get its own classic-tsserver-repair attempt: the
|
|
6867
7798
|
// guard is a process-lifetime flag (see resetClassicTsRepairGuard), so a
|
|
6868
7799
|
// repair that failed transiently in an earlier session must not stay
|
|
@@ -6874,8 +7805,9 @@ export function resetLSPService(options = {}) {
|
|
|
6874
7805
|
// hide behind a leaked/stuck guard from the generation before it.
|
|
6875
7806
|
clearWorkspaceSweepHoldForSessionStart();
|
|
6876
7807
|
}
|
|
6877
|
-
const
|
|
6878
|
-
|
|
7808
|
+
const state = lspProcessState();
|
|
7809
|
+
const retiringService = state.service;
|
|
7810
|
+
state.service = null;
|
|
6879
7811
|
if (!retiringService)
|
|
6880
7812
|
return;
|
|
6881
7813
|
// shutdown() marks the service destroyed synchronously before its first
|
|
@@ -6884,14 +7816,14 @@ export function resetLSPService(options = {}) {
|
|
|
6884
7816
|
// its predecessor. allSettled keeps teardown best-effort without ever
|
|
6885
7817
|
// rejecting (and therefore permanently poisoning) the next generation.
|
|
6886
7818
|
const teardown = retiringService.shutdown(options);
|
|
6887
|
-
const pending =
|
|
6888
|
-
? [
|
|
7819
|
+
const pending = state.generationHandoff
|
|
7820
|
+
? [state.generationHandoff, teardown]
|
|
6889
7821
|
: [teardown];
|
|
6890
7822
|
const handoff = Promise.allSettled(pending).then(() => undefined);
|
|
6891
|
-
|
|
7823
|
+
state.generationHandoff = handoff;
|
|
6892
7824
|
void handoff.then(() => {
|
|
6893
|
-
if (
|
|
6894
|
-
|
|
7825
|
+
if (state.generationHandoff === handoff) {
|
|
7826
|
+
state.generationHandoff = undefined;
|
|
6895
7827
|
}
|
|
6896
7828
|
});
|
|
6897
7829
|
}
|