@herbertgao/pi-extensions 2026.9.0 → 2026.9.2
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/node_modules/@czottmann/pi-automode/CHANGELOG.md +13 -1
- package/node_modules/@czottmann/pi-automode/README.md +20 -0
- package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +1 -1
- package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +2 -1
- package/node_modules/@czottmann/pi-automode/docs/defaults.md +3 -2
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +38 -2
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +5 -3
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +11 -3
- package/node_modules/@czottmann/pi-automode/package.json +1 -1
- package/node_modules/@narumitw/pi-btw/README.md +57 -141
- package/node_modules/@narumitw/pi-btw/dist/index.ts +466 -41
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
- package/node_modules/@narumitw/pi-btw/docs/workflows.md +74 -0
- package/node_modules/@narumitw/pi-btw/package.json +2 -1
- package/node_modules/@narumitw/pi-btw/src/btw.ts +15 -1
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +32 -2
- package/node_modules/@narumitw/pi-btw/src/keybindings.ts +330 -0
- package/node_modules/@narumitw/pi-btw/src/menu.ts +136 -19
- package/node_modules/@narumitw/pi-btw/src/settings.ts +32 -0
- package/node_modules/@narumitw/pi-btw/src/side-thread.ts +57 -5
- package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +52 -30
- package/node_modules/@pi-plugins/fast-mode/README.md +5 -3
- package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +20 -55
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/package.json +1 -1
- package/node_modules/pi-lens/CHANGELOG.md +1072 -0
- package/node_modules/pi-lens/README.md +3 -0
- package/node_modules/pi-lens/config/biome/core.jsonc +11 -2
- package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +13 -1
- package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +2 -9
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +927 -80
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +1 -1
- package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +13 -4
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +123 -3
- package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +60 -5
- package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +2 -9
- package/node_modules/pi-lens/dist/clients/ast-grep-types.js +2 -0
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +133 -3
- package/node_modules/pi-lens/dist/clients/biome-client.js +9 -2
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +14 -6
- package/node_modules/pi-lens/dist/clients/bootstrap.js +509 -73
- package/node_modules/pi-lens/dist/clients/bounded-cache.js +152 -12
- package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +1 -1
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +59 -9
- package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +113 -0
- package/node_modules/pi-lens/dist/clients/bus-events-logger.js +3 -11
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +31 -4
- package/node_modules/pi-lens/dist/clients/cache-manager.js +105 -6
- package/node_modules/pi-lens/dist/clients/cache-observability.js +40 -19
- package/node_modules/pi-lens/dist/clients/cargo-manifest.js +422 -0
- package/node_modules/pi-lens/dist/clients/cascade-logger.js +2 -9
- package/node_modules/pi-lens/dist/clients/child-unref.js +1 -19
- package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +13 -3
- package/node_modules/pi-lens/dist/clients/complexity-client.js +16 -5
- package/node_modules/pi-lens/dist/clients/config-core/deny.js +221 -0
- package/node_modules/pi-lens/dist/clients/config-core/index.js +50 -0
- package/node_modules/pi-lens/dist/clients/config-core/merge.js +357 -0
- package/node_modules/pi-lens/dist/clients/config-core/normalize.js +340 -0
- package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +248 -0
- package/node_modules/pi-lens/dist/clients/config-core/provenance.js +164 -0
- package/node_modules/pi-lens/dist/clients/config-core/records.js +218 -0
- package/node_modules/pi-lens/dist/clients/config-core/resolve.js +125 -0
- package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +78 -0
- package/node_modules/pi-lens/dist/clients/config-core/schema.js +165 -0
- package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +281 -0
- package/node_modules/pi-lens/dist/clients/config-locations.js +160 -0
- package/node_modules/pi-lens/dist/clients/config-resolve.js +789 -0
- package/node_modules/pi-lens/dist/clients/config-schema.js +234 -0
- package/node_modules/pi-lens/dist/clients/config-warn.js +407 -0
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +19 -14
- package/node_modules/pi-lens/dist/clients/dead-code-logger.js +2 -6
- package/node_modules/pi-lens/dist/clients/deadline-utils.js +178 -0
- package/node_modules/pi-lens/dist/clients/debug-handles.js +2 -2
- package/node_modules/pi-lens/dist/clients/debug-heap.js +3 -3
- package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +106 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +113 -7
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +8 -19
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +1 -1
- package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +12 -9
- package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +15 -5
- package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +0 -4
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +162 -44
- package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +97 -0
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +27 -8
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +0 -6
- package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +0 -3
- package/node_modules/pi-lens/dist/clients/dispatch/rules/high-complexity.js +2 -2
- package/node_modules/pi-lens/dist/clients/dispatch/rules/high-fan-out.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +383 -27
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +4 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +1 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +1 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +14 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +6 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +7 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +1 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +1 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +1 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +4 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -67
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +194 -80
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +84 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils.js +0 -21
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +2 -20
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/utils/format-utils.js +2 -2
- package/node_modules/pi-lens/dist/clients/disposition-logger.js +3 -10
- package/node_modules/pi-lens/dist/clients/effective-config.js +403 -0
- package/node_modules/pi-lens/dist/clients/error-class.js +23 -0
- package/node_modules/pi-lens/dist/clients/event-loop-hold.js +274 -0
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +2 -2
- package/node_modules/pi-lens/dist/clients/extension-log.js +2 -2
- package/node_modules/pi-lens/dist/clients/feature-hints.js +2 -1
- package/node_modules/pi-lens/dist/clients/file-kinds.js +1 -38
- package/node_modules/pi-lens/dist/clients/file-time.js +7 -2
- package/node_modules/pi-lens/dist/clients/file-utils.js +117 -10
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +50 -13
- package/node_modules/pi-lens/dist/clients/format-service.js +6 -2
- package/node_modules/pi-lens/dist/clients/formatters.js +385 -104
- package/node_modules/pi-lens/dist/clients/freshness-cadence.js +17 -0
- package/node_modules/pi-lens/dist/clients/generated-artifacts.js +116 -22
- package/node_modules/pi-lens/dist/clients/generation-guard.js +4 -9
- package/node_modules/pi-lens/dist/clients/git-guard.js +6 -1
- package/node_modules/pi-lens/dist/clients/go-client.js +39 -0
- package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +252 -0
- package/node_modules/pi-lens/dist/clients/grammar-source.js +3 -3
- package/node_modules/pi-lens/dist/clients/hashline-anchor.js +424 -0
- package/node_modules/pi-lens/dist/clients/hook-budgets.js +76 -0
- package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +5 -2
- package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
- package/node_modules/pi-lens/dist/clients/installer/index.js +289 -59
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +84 -18
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +110 -236
- package/node_modules/pi-lens/dist/clients/instance-registry.js +6 -3
- package/node_modules/pi-lens/dist/clients/language-profile.js +0 -9
- package/node_modules/pi-lens/dist/clients/language-registry.js +599 -0
- package/node_modules/pi-lens/dist/clients/latency-logger.js +165 -9
- package/node_modules/pi-lens/dist/clients/ledger-bounds.js +34 -1
- package/node_modules/pi-lens/dist/clients/lens-config.js +160 -30
- package/node_modules/pi-lens/dist/clients/lens-engine.js +33 -41
- package/node_modules/pi-lens/dist/clients/lens-events.js +1 -1
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +77 -6
- package/node_modules/pi-lens/dist/clients/lens-map.js +1 -1
- package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -38
- package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +3 -1
- package/node_modules/pi-lens/dist/clients/lsp/client.js +172 -33
- package/node_modules/pi-lens/dist/clients/lsp/config.js +484 -158
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/edits.js +18 -5
- package/node_modules/pi-lens/dist/clients/lsp/index.js +892 -96
- package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/language.js +18 -177
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +5 -18
- package/node_modules/pi-lens/dist/clients/lsp/server.js +284 -147
- package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +116 -23
- package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +2 -7
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +0 -1
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +14 -7
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +6 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp-document-symbols.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +163 -21
- package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +36 -0
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +33 -4
- package/node_modules/pi-lens/dist/clients/mcp/session.js +5 -16
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +8 -3
- package/node_modules/pi-lens/dist/clients/metrics-history.js +28 -107
- package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +3 -5
- package/node_modules/pi-lens/dist/clients/module-report.js +21 -46
- package/node_modules/pi-lens/dist/clients/mutating-tool.js +651 -0
- package/node_modules/pi-lens/dist/clients/mutation-attribution.js +368 -0
- package/node_modules/pi-lens/dist/clients/mutation-bridge.js +240 -0
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +247 -24
- package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +101 -0
- package/node_modules/pi-lens/dist/clients/observed-mutation.js +1215 -0
- package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +70 -28
- package/node_modules/pi-lens/dist/clients/opengrep-config.js +5 -1
- package/node_modules/pi-lens/dist/clients/package-manager.js +195 -26
- package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +359 -80
- package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +23 -7
- package/node_modules/pi-lens/dist/clients/path-keyed-map.js +21 -2
- package/node_modules/pi-lens/dist/clients/path-utils.js +451 -15
- package/node_modules/pi-lens/dist/clients/performance-report.js +2 -2
- package/node_modules/pi-lens/dist/clients/persist-debounce.js +8 -1
- package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +114 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +79 -18
- package/node_modules/pi-lens/dist/clients/probe-home-state.js +228 -0
- package/node_modules/pi-lens/dist/clients/process-bridge.js +66 -0
- package/node_modules/pi-lens/dist/clients/process-snapshot.js +68 -0
- package/node_modules/pi-lens/dist/clients/project-changes.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/jscpd.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/knip.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/opengrep.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +28 -4
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +51 -20
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +438 -122
- package/node_modules/pi-lens/dist/clients/project-scan-policy.js +3 -51
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +52 -24
- package/node_modules/pi-lens/dist/clients/python-environment.js +217 -0
- package/node_modules/pi-lens/dist/clients/python-provenance.js +639 -0
- package/node_modules/pi-lens/dist/clients/quiet-window.js +6 -1
- package/node_modules/pi-lens/dist/clients/read-bridge.js +13 -21
- package/node_modules/pi-lens/dist/clients/read-expansion.js +9 -46
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +16 -5
- package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +364 -212
- package/node_modules/pi-lens/dist/clients/read-guard.js +26 -2
- package/node_modules/pi-lens/dist/clients/recent-touches.js +2 -2
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +195 -96
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +155 -114
- package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +1 -1
- package/node_modules/pi-lens/dist/clients/review-graph/service.js +55 -3
- package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +7 -15
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +10 -74
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +9 -3
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +19 -3
- package/node_modules/pi-lens/dist/clients/runtime-context.js +49 -16
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +25 -6
- package/node_modules/pi-lens/dist/clients/runtime-session.js +184 -32
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +179 -36
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +797 -243
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +1028 -53
- package/node_modules/pi-lens/dist/clients/rust-client.js +31 -0
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +33 -10
- package/node_modules/pi-lens/dist/clients/sanitize.js +7 -12
- package/node_modules/pi-lens/dist/clients/scan-utils.js +1 -56
- package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +1 -3
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +3 -0
- package/node_modules/pi-lens/dist/clients/session-lifecycle.js +1 -1
- package/node_modules/pi-lens/dist/clients/session-start-observability.js +79 -0
- package/node_modules/pi-lens/dist/clients/session-summary.js +0 -24
- package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +12 -3
- package/node_modules/pi-lens/dist/clients/sgconfig.js +7 -6
- package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +1 -1
- package/node_modules/pi-lens/dist/clients/skills-resolver.js +105 -0
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +4 -4
- package/node_modules/pi-lens/dist/clients/startup-scan.js +2 -10
- package/node_modules/pi-lens/dist/clients/string-utils.js +13 -0
- package/node_modules/pi-lens/dist/clients/subagent-mode.js +17 -4
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +176 -39
- package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +244 -0
- package/node_modules/pi-lens/dist/clients/tool-definition.js +41 -1
- package/node_modules/pi-lens/dist/clients/tool-policy.js +92 -22
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +40 -40
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +87 -100
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +2 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +81 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +51 -46
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +28 -0
- package/node_modules/pi-lens/dist/clients/tui-fit.js +0 -4
- package/node_modules/pi-lens/dist/clients/typos-config.js +5 -0
- package/node_modules/pi-lens/dist/clients/user-notify.js +6 -6
- package/node_modules/pi-lens/dist/clients/widget-state.js +191 -26
- package/node_modules/pi-lens/dist/clients/word-index-logger.js +2 -9
- package/node_modules/pi-lens/dist/clients/word-index-store.js +23 -12
- package/node_modules/pi-lens/dist/clients/word-index.js +252 -47
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +8 -1
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +4 -1
- package/node_modules/pi-lens/dist/index.js +65899 -55340
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +3 -1
- package/node_modules/pi-lens/dist/mcp/build-staleness.js +1 -1
- package/node_modules/pi-lens/dist/mcp/server.js +127 -5
- package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +583 -0
- package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +129 -0
- package/node_modules/pi-lens/dist/tools/effective-config.js +89 -0
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +96 -25
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +104 -86
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +37 -16
- package/node_modules/pi-lens/dist/tools/lsp-structured-output.js +3 -3
- package/node_modules/pi-lens/dist/tools/render-compact.js +1 -1
- package/node_modules/pi-lens/dist/tools/shared.js +0 -1
- package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -2
- package/node_modules/pi-lens/docs/agent-guide.md +64 -1
- package/node_modules/pi-lens/docs/configuration.md +222 -0
- package/node_modules/pi-lens/docs/dependencies.md +3 -3
- package/node_modules/pi-lens/docs/features.md +44 -5
- package/node_modules/pi-lens/docs/globalconfig.md +20 -0
- package/node_modules/pi-lens/docs/language-coverage.md +2 -2
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +9 -1
- package/node_modules/pi-lens/docs/pi-lens-fixer.md +25 -0
- package/node_modules/pi-lens/docs/pi-lens-investigator.md +25 -0
- package/node_modules/pi-lens/docs/pi-lens-reviewer.md +33 -0
- package/node_modules/pi-lens/docs/pi-lens-subagent.md +49 -0
- package/node_modules/pi-lens/docs/pi-lens-warden.md +55 -0
- package/node_modules/pi-lens/docs/public-api-stability.md +359 -0
- package/node_modules/pi-lens/docs/release-qa-baseline.md +190 -0
- package/node_modules/pi-lens/docs/servercapabilities.md +7 -3
- package/node_modules/pi-lens/docs/subagent-compat.md +110 -30
- package/node_modules/pi-lens/docs/tree-sitter_rules_catalog.md +2 -2
- package/node_modules/pi-lens/docs/word-index.md +1 -1
- package/node_modules/pi-lens/package.json +18 -35
- package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
- package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
- package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
- package/node_modules/pi-lens/rules/typos/_typos.toml +4 -2
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +192 -1
- package/node_modules/pi-lens/scripts/install-selftest.mjs +58 -2
- package/node_modules/pi-lens/scripts/lib/skills-predicate.mjs +129 -0
- package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +1 -1
- package/node_modules/pi-lens/scripts/rpc-load-check.mjs +3 -1
- package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +4 -2
- package/node_modules/pi-web-access/CHANGELOG.md +28 -0
- package/node_modules/pi-web-access/README.md +62 -15
- package/node_modules/pi-web-access/curator-page.ts +3 -1
- package/node_modules/pi-web-access/curator-server.ts +5 -1
- package/node_modules/pi-web-access/gemini-search.ts +8 -4
- package/node_modules/pi-web-access/github-extract.ts +242 -1
- package/node_modules/pi-web-access/index.ts +118 -64
- package/node_modules/pi-web-access/mistral-search.ts +281 -0
- package/node_modules/pi-web-access/package.json +2 -2
- package/node_modules/pi-web-access/perplexity.ts +14 -1
- package/node_modules/pi-web-access/utils.ts +23 -6
- package/node_modules/pi-web-access/xai-search.ts +96 -33
- package/package.json +6 -6
|
@@ -12,6 +12,7 @@ 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";
|
|
@@ -22,7 +23,11 @@ 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 { HOOK_WALL_BUDGET_MS } from "../hook-budgets.js";
|
|
29
|
+
import { getAmbientAbortSignal } from "../safe-spawn.js";
|
|
30
|
+
import { abortDeferredLspWork } from "../deferred-lsp-work.js";
|
|
26
31
|
import { acquireWorkspaceSweepHold, clearWorkspaceSweepHoldForSessionStart, } from "./workspace-sweep-hold.js";
|
|
27
32
|
import { DocumentDriftTracker, fingerprintDocumentContent, } from "./document-drift.js";
|
|
28
33
|
import { markPendingAuxiliaryCoverage, napiFallbackCoveredSince, } from "./pending-aux-coverage.js";
|
|
@@ -37,7 +42,7 @@ import { getServersForFileWithConfig, getServerInitOverride, } from "./config.js
|
|
|
37
42
|
import { getSessionRootsForTelemetry, isOutsideAllSessionRoots, } from "./session-roots.js";
|
|
38
43
|
import { getProcessSingleton } from "../process-singletons.js";
|
|
39
44
|
import { getLanguageId } from "./language.js";
|
|
40
|
-
import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, } from "./server.js";
|
|
45
|
+
import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, resetLspLaunchAvailabilityGeneration, } from "./server.js";
|
|
41
46
|
import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
|
|
42
47
|
const WORKSPACE_ATTRIBUTION_CLIENT_CAP = 16;
|
|
43
48
|
import { raceToCompletion } from "./aggregation.js";
|
|
@@ -468,6 +473,19 @@ function notifyWriteBudgetMs() {
|
|
|
468
473
|
const raw = Number(process.env.PI_LENS_LSP_NOTIFY_BUDGET_MS);
|
|
469
474
|
return Number.isFinite(raw) && raw > 0 ? raw : 2000;
|
|
470
475
|
}
|
|
476
|
+
// #2239: the SAME effective per-server wait floor `getClientForFile` uses to
|
|
477
|
+
// size its own acquisition race — the caller's declared budget, raised to
|
|
478
|
+
// whatever the file's primary server(s) configure via `clientWaitTimeoutMs`
|
|
479
|
+
// (Ruby 30s, and the Bash/JSON/Vue/Svelte/Prisma overrides #2233 added). A
|
|
480
|
+
// cold primary spawn is allowed to run this long, so anything downstream that
|
|
481
|
+
// charges itself against `maxWaitMs` alone — rather than this floor — sees an
|
|
482
|
+
// already-elapsed time it never budgeted for and clamps to zero.
|
|
483
|
+
function primaryServerWaitFloorMs(filePath, maxWaitMs) {
|
|
484
|
+
const serverWaitOverrideMs = getServersForFileWithConfig(filePath)
|
|
485
|
+
.filter((s) => s.role !== "auxiliary")
|
|
486
|
+
.reduce((max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0), 0);
|
|
487
|
+
return Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
|
|
488
|
+
}
|
|
471
489
|
// #1459: how long ONE auxiliary notify write may stay outstanding before the
|
|
472
490
|
// server counts as wedged rather than merely slow. A scanner whose per-file work
|
|
473
491
|
// exceeds the write budget is normal (opengrep routinely needs >2s on a large
|
|
@@ -479,6 +497,30 @@ const NOTIFY_WEDGED_BUDGET_MULTIPLIER = 5;
|
|
|
479
497
|
function notifyWedgedMs() {
|
|
480
498
|
return notifyWriteBudgetMs() * NOTIFY_WEDGED_BUDGET_MULTIPLIER;
|
|
481
499
|
}
|
|
500
|
+
// #2358: the wedge window's teardown decision gained a liveness discriminator.
|
|
501
|
+
// The fixed window above stays the FLOOR; a server whose drain history says it
|
|
502
|
+
// answers per-write in `ewmaMs` with `unacked` documents queued earns
|
|
503
|
+
// `k x ewmaMs x unacked` of patience instead — the issue's "a server that
|
|
504
|
+
// historically answers in 855 ms with 8 writes queued earns 15-20 s". The cap
|
|
505
|
+
// bounds the whole thing so a busy-but-never-draining server still respawns.
|
|
506
|
+
const NOTIFY_WEDGED_EWMA_MULTIPLIER = 2;
|
|
507
|
+
const NOTIFY_WEDGED_CAP_MS = 60_000;
|
|
508
|
+
function notifyWedgedCapMs() {
|
|
509
|
+
const raw = Number(process.env.PI_LENS_LSP_NOTIFY_WEDGED_CAP_MS);
|
|
510
|
+
return Number.isFinite(raw) && raw > 0 ? raw : NOTIFY_WEDGED_CAP_MS;
|
|
511
|
+
}
|
|
512
|
+
// #2358: how long the CPU-liveness discriminator watches the server between
|
|
513
|
+
// two process-CPU samples before deciding "busy" vs "flat". Only ever spent
|
|
514
|
+
// on a server whose write is already past its patience window — never on the
|
|
515
|
+
// per-edit hot path.
|
|
516
|
+
function notifyStallCpuSampleMs() {
|
|
517
|
+
const raw = Number(process.env.PI_LENS_LSP_NOTIFY_STALL_CPU_SAMPLE_MS);
|
|
518
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 1200;
|
|
519
|
+
}
|
|
520
|
+
// A server burning more than this percent of one core across the sample
|
|
521
|
+
// window counts as BUSY (progressing), not flat (wedged). A single-threaded
|
|
522
|
+
// scanner under burst sits near 100; an idle or dead one near 0.
|
|
523
|
+
const NOTIFY_STALL_CPU_BUSY_FLOOR_PERCENT = 10;
|
|
482
524
|
// #1714: how many document notifies one auxiliary may hold UNACKNOWLEDGED
|
|
483
525
|
// before the next notify has to prove the server drained its input.
|
|
484
526
|
//
|
|
@@ -605,6 +647,12 @@ export class LSPService {
|
|
|
605
647
|
/** Server/root pairs that already emitted unavailable for the current occurrence. */
|
|
606
648
|
unavailableLogged = new Set();
|
|
607
649
|
optionalDisabled = new Set();
|
|
650
|
+
/**
|
|
651
|
+
* One first-contact diagnostic wait per custom server in this service
|
|
652
|
+
* generation. Concurrent touches share the probe; a hook cutoff never arms
|
|
653
|
+
* the navigation-only latch, so the next touch can try again.
|
|
654
|
+
*/
|
|
655
|
+
firstContactWaits = new Map();
|
|
608
656
|
/**
|
|
609
657
|
* #1934 review F1: what the last COMPLETED `spawnClient` call for a
|
|
610
658
|
* (server, root) key decided, written by that call at every point it
|
|
@@ -712,6 +760,22 @@ export class LSPService {
|
|
|
712
760
|
* successful write clears its entry.
|
|
713
761
|
*/
|
|
714
762
|
notifyWriteBackpressureStreak = new Map();
|
|
763
|
+
/**
|
|
764
|
+
* #2358: EWMA of one auxiliary's per-document drain latency
|
|
765
|
+
* (ms per unacknowledged write), keyed by "serverId:normalizedRoot".
|
|
766
|
+
*
|
|
767
|
+
* Updated from DRAINED notify barriers only: the round-trip proves the
|
|
768
|
+
* server processed its `outstanding` backlog, and
|
|
769
|
+
* duration / outstanding is the per-write service time a single-threaded
|
|
770
|
+
* scanner actually achieves under load. The breaker grants a wedged write
|
|
771
|
+
* `k x ewma x unacked` of patience from it, so a slow-but-alive scanner is
|
|
772
|
+
* not killed at the fixed window by construction (#2358).
|
|
773
|
+
*
|
|
774
|
+
* Cleared by the service teardown (via `session_start`) like the rest of
|
|
775
|
+
* the breaker state; a stale throughput estimate from a previous session's
|
|
776
|
+
* client must not price the next one.
|
|
777
|
+
*/
|
|
778
|
+
auxNotifyDrainLatencyEwma = new Map();
|
|
715
779
|
/**
|
|
716
780
|
* #1714: unacknowledged auxiliary document notifies per server key
|
|
717
781
|
* ("serverId:normalizedRoot" — the same identity as every other gate here).
|
|
@@ -748,6 +812,15 @@ export class LSPService {
|
|
|
748
812
|
* never lands is still demoted (see {@link demoteForNotifyStall}).
|
|
749
813
|
*/
|
|
750
814
|
outstandingAuxNotifyWrites = new Map();
|
|
815
|
+
/**
|
|
816
|
+
* #2356: auxiliary clients removed by the notify-stall breaker are a
|
|
817
|
+
* temporary absence, not a missing scanner. Keep the demotion identity after
|
|
818
|
+
* deleting the client so the turn-end late-coverage probe can re-arm the
|
|
819
|
+
* pending pair until a replacement is published (or its existing bounded
|
|
820
|
+
* re-arm ceiling is reached). A successful replacement removes the marker.
|
|
821
|
+
*/
|
|
822
|
+
static MAX_NOTIFY_STALL_DEMOTIONS = 50;
|
|
823
|
+
notifyStallDemotions = new BoundedFifoMap(LSPService.MAX_NOTIFY_STALL_DEMOTIONS);
|
|
751
824
|
/** LRU clock for capacity eviction, keyed by the canonical server/root key. */
|
|
752
825
|
clientLastUsedAt = new Map();
|
|
753
826
|
/**
|
|
@@ -767,6 +840,8 @@ export class LSPService {
|
|
|
767
840
|
clientSpawnGate = Promise.resolve();
|
|
768
841
|
/** True after shutdown() has been called; blocks new operations */
|
|
769
842
|
isDestroyed = false;
|
|
843
|
+
/** Aborts bounded in-flight service work when this generation is retired. */
|
|
844
|
+
shutdownController = new AbortController();
|
|
770
845
|
/**
|
|
771
846
|
* #850: teardown completion for every singleton generation retired before
|
|
772
847
|
* this service was published. Only replacement services receive one; direct
|
|
@@ -786,6 +861,8 @@ export class LSPService {
|
|
|
786
861
|
clientSpawnedAt: new Map(),
|
|
787
862
|
demonstratedReady: new Set(),
|
|
788
863
|
demonstratedCold: new Set(),
|
|
864
|
+
diagnosticsPublished: new Set(),
|
|
865
|
+
diagnosticsUnsupported: new Set(),
|
|
789
866
|
};
|
|
790
867
|
}
|
|
791
868
|
/**
|
|
@@ -948,6 +1025,7 @@ export class LSPService {
|
|
|
948
1025
|
return false;
|
|
949
1026
|
}
|
|
950
1027
|
const [victimKey, victimClient] = victim;
|
|
1028
|
+
this.releaseOutstandingAuxNotifyWrite(victimKey);
|
|
951
1029
|
await victimClient.shutdown({ reason: "client_ceiling_lru" });
|
|
952
1030
|
this.state.clients.delete(victimKey);
|
|
953
1031
|
this.state.clientSpawnedAt.delete(victimKey);
|
|
@@ -964,6 +1042,15 @@ export class LSPService {
|
|
|
964
1042
|
clearTimeout(timer);
|
|
965
1043
|
this.typeScriptIdleTimers.delete(key);
|
|
966
1044
|
}
|
|
1045
|
+
/** Release a notify token and its timer when its client generation retires. */
|
|
1046
|
+
releaseOutstandingAuxNotifyWrite(key, token) {
|
|
1047
|
+
const current = this.outstandingAuxNotifyWrites.get(key);
|
|
1048
|
+
if (!current || (token !== undefined && current !== token))
|
|
1049
|
+
return;
|
|
1050
|
+
clearTimeout(current.wedgeTimer);
|
|
1051
|
+
this.outstandingAuxNotifyWrites.delete(key);
|
|
1052
|
+
current.resolveSettled();
|
|
1053
|
+
}
|
|
967
1054
|
scheduleTypeScriptIdleEviction(key) {
|
|
968
1055
|
if (!key.startsWith("typescript:"))
|
|
969
1056
|
return;
|
|
@@ -989,6 +1076,7 @@ export class LSPService {
|
|
|
989
1076
|
// Publish the cold state synchronously before awaiting teardown. A request
|
|
990
1077
|
// arriving while shutdown is in progress therefore waits on the spawn gate
|
|
991
1078
|
// and creates a fresh client; it can never receive this retiring one.
|
|
1079
|
+
this.releaseOutstandingAuxNotifyWrite(key);
|
|
992
1080
|
this.state.clients.delete(key);
|
|
993
1081
|
this.state.clientSpawnedAt.delete(key);
|
|
994
1082
|
this.state.demonstratedReady.delete(key);
|
|
@@ -1130,23 +1218,130 @@ export class LSPService {
|
|
|
1130
1218
|
return;
|
|
1131
1219
|
}
|
|
1132
1220
|
this.notifyWriteBackpressureStreak.delete(key);
|
|
1133
|
-
|
|
1221
|
+
// #2358: the consecutive-timeout ladder lands here too, and its verdict
|
|
1222
|
+
// must not kill a server that is demonstrably BUSY. The guarded demotion
|
|
1223
|
+
// samples the live process CPU and merely resets the ladder (the next
|
|
1224
|
+
// three timeouts re-try) when the server is progressing — the wedged-write
|
|
1225
|
+
// path's cap owns a busy-but-never-draining server, not this streak.
|
|
1226
|
+
void this.demoteNotifyStallCpuGuarded(key, entry, filePath, {
|
|
1134
1227
|
consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
|
|
1135
1228
|
});
|
|
1136
1229
|
}
|
|
1230
|
+
/**
|
|
1231
|
+
* #2358: liveness-guarded teardown for the STREAK ladder.
|
|
1232
|
+
*
|
|
1233
|
+
* The wedged-write path ({@link decideNotifyStallTeardown}) keeps its own
|
|
1234
|
+
* re-arm loop so a busy server's outstanding write stays un-torn-down; the
|
|
1235
|
+
* streak ladder has no write to hold — it simply does not demote, and the
|
|
1236
|
+
* next three timeouts re-attempt. Generation-guarded after the await so a
|
|
1237
|
+
* client replaced mid-sample is never demoted for a verdict about its
|
|
1238
|
+
* predecessor.
|
|
1239
|
+
*/
|
|
1240
|
+
async demoteNotifyStallCpuGuarded(key, entry, filePath, reason) {
|
|
1241
|
+
if (this.state.clients.get(key) !== entry.client)
|
|
1242
|
+
return;
|
|
1243
|
+
// The CPU verdict is asynchronous, but the streak has already committed to
|
|
1244
|
+
// this client's teardown path. Release the TypeScript idle-timer ownership
|
|
1245
|
+
// before sampling so another idle callback cannot target the same client
|
|
1246
|
+
// while the verdict is in flight. A BUSY verdict below re-arms a fresh timer.
|
|
1247
|
+
this.clearTypeScriptIdleTimer(key);
|
|
1248
|
+
const verdict = await this.notifyStallCpuVerdict(entry);
|
|
1249
|
+
if (verdict.cpuVerdict === "busy") {
|
|
1250
|
+
if (this.state.clients.get(key) === entry.client) {
|
|
1251
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
1252
|
+
}
|
|
1253
|
+
this.logNotifyStallCpuBusy(key, entry, filePath, 0, verdict);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (this.state.clients.get(key) !== entry.client)
|
|
1257
|
+
return;
|
|
1258
|
+
this.demoteForNotifyStall(key, entry, filePath, {
|
|
1259
|
+
...reason,
|
|
1260
|
+
discriminator: "consecutive-timeouts",
|
|
1261
|
+
cpuVerdict: verdict.cpuVerdict,
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* #2358: classify a stalled server's live process CPU.
|
|
1266
|
+
*
|
|
1267
|
+
* A client with no process handle (a test/mock double, a legacy client)
|
|
1268
|
+
* reports "unmeasured" — the caller keeps the pre-#2358 demote-at-budget
|
|
1269
|
+
* behavior, because there is no liveness evidence to override it. A sampled
|
|
1270
|
+
* process that burns a core is BUSY; anything else is FLAT, including a
|
|
1271
|
+
* query that failed to resolve the pid — a server we cannot measure is not
|
|
1272
|
+
* proven busy, and the replacement self-heal is cheap.
|
|
1273
|
+
*/
|
|
1274
|
+
async notifyStallCpuVerdict(entry) {
|
|
1275
|
+
const pid = entry.client.getProcessPid?.();
|
|
1276
|
+
if (pid === undefined || !(Number.isFinite(pid) && pid > 0)) {
|
|
1277
|
+
return { cpuVerdict: "unmeasured", cpuPercent: null };
|
|
1278
|
+
}
|
|
1279
|
+
const sample = await sampleProcessTreeCpuPercent(pid, notifyStallCpuSampleMs(), NOTIFY_STALL_CPU_BUSY_FLOOR_PERCENT);
|
|
1280
|
+
if (!sample.measured) {
|
|
1281
|
+
return { cpuVerdict: "unmeasured", cpuPercent: null };
|
|
1282
|
+
}
|
|
1283
|
+
if (sample.busy) {
|
|
1284
|
+
return { cpuVerdict: "busy", cpuPercent: sample.cpuPercent };
|
|
1285
|
+
}
|
|
1286
|
+
return { cpuVerdict: "flat", cpuPercent: sample.cpuPercent };
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* #2358: one bounded row per busy defer. Not a failure record — the
|
|
1290
|
+
* discrimination succeeded — so it is a plain latency phase; its volume is
|
|
1291
|
+
* bounded by the re-arm cadence of one outstanding write (at most the wedge
|
|
1292
|
+
* cap divided by the budget, ~6 fires at defaults).
|
|
1293
|
+
*/
|
|
1294
|
+
logNotifyStallCpuBusy(key, entry, filePath, outstandingMs, verdict, extra = {}) {
|
|
1295
|
+
emitBounded("lsp_notify_stall_cpu_busy", `${key}:${normalizeMapKey(filePath)}`, {
|
|
1296
|
+
type: "phase",
|
|
1297
|
+
durationMs: outstandingMs,
|
|
1298
|
+
metadata: {
|
|
1299
|
+
serverId: entry.info.id,
|
|
1300
|
+
clientKey: key,
|
|
1301
|
+
outstandingMs,
|
|
1302
|
+
cpuPercent: verdict.cpuPercent ?? null,
|
|
1303
|
+
...extra,
|
|
1304
|
+
},
|
|
1305
|
+
}, {
|
|
1306
|
+
ledgerKind: "lsp-notify-stall-cpu-busy",
|
|
1307
|
+
risingEdgePer: "identity",
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* #2358: the patience window a wedged auxiliary write earns.
|
|
1312
|
+
*
|
|
1313
|
+
* max(fixed floor, k x EWMA per-write latency x unacked depth), capped so a
|
|
1314
|
+
* busy-but-never-draining server still respawns within the cap. The EWMA
|
|
1315
|
+
* comes from DRAINED notify barriers (see {@link noteAuxNotifyDrainLatency});
|
|
1316
|
+
* with no history the fixed floor (the pre-#2358 window) stands.
|
|
1317
|
+
*/
|
|
1318
|
+
auxNotifyWedgeBudgetMs(key) {
|
|
1319
|
+
let budgetMs = notifyWedgedMs();
|
|
1320
|
+
const ewma = this.auxNotifyDrainLatencyEwma.get(key);
|
|
1321
|
+
if (ewma !== undefined && ewma > 0) {
|
|
1322
|
+
const unacked = this.auxNotifyInflight.get(key)?.unacked ?? 0;
|
|
1323
|
+
budgetMs = Math.max(budgetMs, NOTIFY_WEDGED_EWMA_MULTIPLIER * ewma * unacked);
|
|
1324
|
+
}
|
|
1325
|
+
return Math.min(budgetMs, notifyWedgedCapMs());
|
|
1326
|
+
}
|
|
1137
1327
|
/**
|
|
1138
1328
|
* The demotion itself, shared by the #743 consecutive-timeout streak and the
|
|
1139
1329
|
* #1459 wedged-write rule. Both mean the same thing — this client's input path
|
|
1140
1330
|
* is not moving — and both need the same teardown.
|
|
1141
1331
|
*/
|
|
1142
1332
|
demoteForNotifyStall(key, entry, filePath, reason) {
|
|
1333
|
+
// An async verdict belongs to one client generation. Never let a
|
|
1334
|
+
// predecessor's decision delete or cool down its replacement.
|
|
1335
|
+
if (this.state.clients.get(key) !== entry.client)
|
|
1336
|
+
return;
|
|
1143
1337
|
this.notifyWriteBackpressureStreak.delete(key);
|
|
1144
|
-
this.
|
|
1338
|
+
this.releaseOutstandingAuxNotifyWrite(key);
|
|
1145
1339
|
// #1714: the demoted client is torn down, so its backlog count describes a
|
|
1146
1340
|
// process that no longer exists. Leaving it would make the replacement start
|
|
1147
1341
|
// at the ceiling and pay a barrier on its first file.
|
|
1148
1342
|
this.auxNotifyInflight.delete(key);
|
|
1149
1343
|
this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
|
|
1344
|
+
this.notifyStallDemotions.set(key, Date.now());
|
|
1150
1345
|
void entry.client.shutdown().catch(() => { });
|
|
1151
1346
|
this.state.clients.delete(key);
|
|
1152
1347
|
this.state.clientSpawnedAt.delete(key);
|
|
@@ -1209,6 +1404,87 @@ export class LSPService {
|
|
|
1209
1404
|
}
|
|
1210
1405
|
this.auxNotifyInflight.set(key, { client, unacked: 1 });
|
|
1211
1406
|
}
|
|
1407
|
+
/**
|
|
1408
|
+
* #2358: fold one DRAINED notify barrier's per-write latency into the
|
|
1409
|
+
* per-key EWMA. A drained round-trip proves the server processed its
|
|
1410
|
+
* `outstanding` backlog after the ping; duration / outstanding is the
|
|
1411
|
+
* per-document service time the single-threaded scanner actually achieves
|
|
1412
|
+
* under load, which is exactly what a busy-server patience window must be
|
|
1413
|
+
* priced from (#2358's evidence: an 855 ms per-answer server with an
|
|
1414
|
+
* 8-write backlog, able to drain in 10-20 s while healthy).
|
|
1415
|
+
*/
|
|
1416
|
+
noteAuxNotifyDrainLatency(key, durationMs, outstanding, record) {
|
|
1417
|
+
if (!(outstanding > 0 && durationMs > 0))
|
|
1418
|
+
return;
|
|
1419
|
+
if (record && this.auxNotifyInflight.get(key) !== record)
|
|
1420
|
+
return;
|
|
1421
|
+
const perWriteMs = durationMs / outstanding;
|
|
1422
|
+
const prev = this.auxNotifyDrainLatencyEwma.get(key);
|
|
1423
|
+
this.auxNotifyDrainLatencyEwma.set(key, prev === undefined ? perWriteMs : 0.5 * perWriteMs + 0.5 * prev);
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* #2358: decide whether a wedged notify write's teardown really fires.
|
|
1427
|
+
*
|
|
1428
|
+
* Before #2358 the breaker killed a server whose write stayed outstanding
|
|
1429
|
+
* past a FIXED window, conflating a dead input path with a busy scanner
|
|
1430
|
+
* draining a burst — the live opengrep kill at #2358's head. The decision
|
|
1431
|
+
* now has two guards, and the record names which one fired:
|
|
1432
|
+
*
|
|
1433
|
+
* - the window is ADAPTIVE — max(fixed floor, k x EWMA per-write latency x
|
|
1434
|
+
* unacked depth), capped at {@link notifyWedgedCapMs} — so a server that
|
|
1435
|
+
* historically answers in 855 ms with 8 writes queued earns 13.7 s (k=2),
|
|
1436
|
+
* not 10 s;
|
|
1437
|
+
* - past the window, the server's process CPU is sampled twice; a BUSY
|
|
1438
|
+
* server is left alone (the write stays outstanding and the caller re-arms
|
|
1439
|
+
* this timer), a FLAT or unmeasured one is torn down.
|
|
1440
|
+
*
|
|
1441
|
+
* The hard cap still kills: a busy server that cannot drain inside it is
|
|
1442
|
+
* damaged, and the replacement is the self-heal the breaker exists to
|
|
1443
|
+
* provide.
|
|
1444
|
+
*/
|
|
1445
|
+
async decideNotifyStallTeardown(key, entry, filePath, token) {
|
|
1446
|
+
if (this.state.clients.get(key) !== entry.client ||
|
|
1447
|
+
this.outstandingAuxNotifyWrites.get(key) !== token) {
|
|
1448
|
+
throw new Error("stale notify generation");
|
|
1449
|
+
}
|
|
1450
|
+
const outstandingMs = Date.now() - token.startedAt;
|
|
1451
|
+
const armedBudgetMs = token.armedBudgetMs;
|
|
1452
|
+
if (outstandingMs >= notifyWedgedCapMs()) {
|
|
1453
|
+
return {
|
|
1454
|
+
action: "demote",
|
|
1455
|
+
reason: {
|
|
1456
|
+
outstandingMs,
|
|
1457
|
+
discriminator: "cap-exceeded",
|
|
1458
|
+
budgetMs: armedBudgetMs,
|
|
1459
|
+
},
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
const verdict = await this.notifyStallCpuVerdict(entry);
|
|
1463
|
+
if (this.state.clients.get(key) !== entry.client ||
|
|
1464
|
+
this.outstandingAuxNotifyWrites.get(key) !== token) {
|
|
1465
|
+
throw new Error("stale notify generation");
|
|
1466
|
+
}
|
|
1467
|
+
if (verdict.cpuVerdict === "busy") {
|
|
1468
|
+
this.logNotifyStallCpuBusy(key, entry, filePath, outstandingMs, verdict, {
|
|
1469
|
+
budgetMs: armedBudgetMs,
|
|
1470
|
+
});
|
|
1471
|
+
return { action: "rearm", budgetMs: this.auxNotifyWedgeBudgetMs(key) };
|
|
1472
|
+
}
|
|
1473
|
+
return {
|
|
1474
|
+
action: "demote",
|
|
1475
|
+
reason: {
|
|
1476
|
+
outstandingMs,
|
|
1477
|
+
discriminator: verdict.cpuVerdict === "unmeasured"
|
|
1478
|
+
? "budget-exceeded"
|
|
1479
|
+
: "budget-exceeded-cpu-flat",
|
|
1480
|
+
budgetMs: armedBudgetMs,
|
|
1481
|
+
ewmaInputMs: this.auxNotifyDrainLatencyEwma.get(key),
|
|
1482
|
+
unackedDepth: this.auxNotifyInflight.get(key)?.unacked ?? 0,
|
|
1483
|
+
cpuVerdict: verdict.cpuVerdict,
|
|
1484
|
+
cpuPercent: verdict.cpuPercent ?? null,
|
|
1485
|
+
},
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1212
1488
|
/**
|
|
1213
1489
|
* #1714: is this auxiliary already holding as many documents as it may?
|
|
1214
1490
|
*
|
|
@@ -1339,11 +1615,17 @@ export class LSPService {
|
|
|
1339
1615
|
// and the waiter is what latches. A second latch here would be
|
|
1340
1616
|
// unreachable, and no test could hold it honest.
|
|
1341
1617
|
}
|
|
1618
|
+
// #2358: a DRAINED round-trip is a per-write latency measurement —
|
|
1619
|
+
// the server really processed its backlog — so it feeds the EWMA
|
|
1620
|
+
// the adaptive wedge window is priced from.
|
|
1621
|
+
const durationMs = Date.now() - startedAt;
|
|
1622
|
+
if (drained)
|
|
1623
|
+
this.noteAuxNotifyDrainLatency(key, durationMs, outstanding, record);
|
|
1342
1624
|
this.noteDrainBarrierOutcome(key, entry, filePath, context, {
|
|
1343
1625
|
unacked: outstanding,
|
|
1344
1626
|
limit,
|
|
1345
1627
|
waitMs,
|
|
1346
|
-
durationMs
|
|
1628
|
+
durationMs,
|
|
1347
1629
|
outcome: drained ? "drained" : "stalled",
|
|
1348
1630
|
});
|
|
1349
1631
|
return drained;
|
|
@@ -1466,7 +1748,7 @@ export class LSPService {
|
|
|
1466
1748
|
// respawned) says nothing about this client's stdin — drop it, so a stale
|
|
1467
1749
|
// entry can never starve a healthy server.
|
|
1468
1750
|
if (outstanding && outstanding.client !== entry.client) {
|
|
1469
|
-
this.
|
|
1751
|
+
this.releaseOutstandingAuxNotifyWrite(clientKey, outstanding);
|
|
1470
1752
|
}
|
|
1471
1753
|
else if (outstanding) {
|
|
1472
1754
|
const outstandingMs = Date.now() - outstanding.startedAt;
|
|
@@ -1503,30 +1785,70 @@ export class LSPService {
|
|
|
1503
1785
|
startedAt: Date.now(),
|
|
1504
1786
|
client: entry.client,
|
|
1505
1787
|
settled,
|
|
1506
|
-
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1788
|
+
armedBudgetMs: 0,
|
|
1789
|
+
// SAFETY: the handle is a torn-initialized placeholder. It is armed
|
|
1790
|
+
// a beat later in this same atomic claim (no await in between) and
|
|
1791
|
+
// thereafter only ever written by this token's own fire/release
|
|
1792
|
+
// closures, so the placeholder value is never read by anyone.
|
|
1793
|
+
wedgeTimer: undefined,
|
|
1794
|
+
resolveSettled: () => resolveSettled?.(),
|
|
1795
|
+
};
|
|
1796
|
+
// A write nothing accepts for the whole wedge window is a dead input
|
|
1797
|
+
// path, not a slow scan. Armed HERE rather than checked by the next
|
|
1798
|
+
// waiter: inside a burst every waiter arrives within one budget, so a
|
|
1799
|
+
// waiter-side check could never see the wedge window elapse and a
|
|
1800
|
+
// wedged scanner was never demoted. Unref'd so it cannot hold a
|
|
1801
|
+
// one-shot host alive, and cleared on release.
|
|
1802
|
+
//
|
|
1803
|
+
// #2358: the window itself is now ADAPTIVE (issue's `max(fixed,
|
|
1804
|
+
// k x EWMA per-answer latency x unacked depth)`, capped), and when it
|
|
1805
|
+
// fires the server's process CPU is sampled before the kill — a busy
|
|
1806
|
+
// server is left alone and the timer re-arms, so a scanner draining a
|
|
1807
|
+
// burst is never killed by construction. Every fire re-checks the
|
|
1808
|
+
// token identity after its awaits, so a release or a concurrent
|
|
1809
|
+
// demotion that lands mid-sample aborts the decision.
|
|
1810
|
+
const fireWedge = async () => {
|
|
1811
|
+
if (this.outstandingAuxNotifyWrites.get(clientKey) !== token ||
|
|
1812
|
+
this.state.clients.get(clientKey) !== entry.client) {
|
|
1813
|
+
this.releaseOutstandingAuxNotifyWrite(clientKey, token);
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
let decision;
|
|
1817
|
+
try {
|
|
1818
|
+
decision = await this.decideNotifyStallTeardown(clientKey, entry, filePath, token);
|
|
1819
|
+
}
|
|
1820
|
+
catch {
|
|
1821
|
+
// A failed decision must not strand the outstanding write or the
|
|
1822
|
+
// waiter: fall back to the pre-#2358 demote-at-budget teardown.
|
|
1823
|
+
decision = {
|
|
1824
|
+
action: "demote",
|
|
1825
|
+
reason: {
|
|
1826
|
+
outstandingMs: Date.now() - token.startedAt,
|
|
1827
|
+
discriminator: "budget-exceeded",
|
|
1828
|
+
budgetMs: token.armedBudgetMs,
|
|
1829
|
+
},
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
if (this.outstandingAuxNotifyWrites.get(clientKey) !== token ||
|
|
1833
|
+
this.state.clients.get(clientKey) !== entry.client) {
|
|
1834
|
+
this.releaseOutstandingAuxNotifyWrite(clientKey, token);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
if (decision.action === "demote") {
|
|
1838
|
+
this.demoteForNotifyStall(clientKey, entry, filePath, decision.reason);
|
|
1518
1839
|
resolveSettled?.();
|
|
1519
|
-
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
token.wedgeTimer = setTimeout(() => void fireWedge(), decision.budgetMs);
|
|
1843
|
+
token.wedgeTimer.unref?.();
|
|
1520
1844
|
};
|
|
1845
|
+
token.armedBudgetMs = this.auxNotifyWedgeBudgetMs(clientKey);
|
|
1846
|
+
token.wedgeTimer = setTimeout(() => void fireWedge(), token.armedBudgetMs);
|
|
1521
1847
|
token.wedgeTimer.unref?.();
|
|
1522
1848
|
this.outstandingAuxNotifyWrites.set(clientKey, token);
|
|
1523
1849
|
return {
|
|
1524
1850
|
release: () => {
|
|
1525
|
-
|
|
1526
|
-
if (this.outstandingAuxNotifyWrites.get(clientKey) === token) {
|
|
1527
|
-
this.outstandingAuxNotifyWrites.delete(clientKey);
|
|
1528
|
-
}
|
|
1529
|
-
resolveSettled?.();
|
|
1851
|
+
this.releaseOutstandingAuxNotifyWrite(clientKey, token);
|
|
1530
1852
|
},
|
|
1531
1853
|
};
|
|
1532
1854
|
}
|
|
@@ -1607,6 +1929,30 @@ export class LSPService {
|
|
|
1607
1929
|
return entries.sort((a, b) => (priority.get(a.serverId) ?? Number.MAX_SAFE_INTEGER) -
|
|
1608
1930
|
(priority.get(b.serverId) ?? Number.MAX_SAFE_INTEGER));
|
|
1609
1931
|
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Documents currently open on any live client rooted at or under `cwd`
|
|
1934
|
+
* (#2430), as normalized path keys.
|
|
1935
|
+
*
|
|
1936
|
+
* The third contributor to the observational mutation net's tracked-file
|
|
1937
|
+
* set, alongside the read-guard's read/write set and the widget's diagnostic
|
|
1938
|
+
* files. It is pure in-memory enumeration — no spawn, no open, no stat —
|
|
1939
|
+
* and it is deliberately capped by the caller rather than here, because the
|
|
1940
|
+
* cap belongs to the sweep's budget, not to the pool's bookkeeping.
|
|
1941
|
+
*/
|
|
1942
|
+
getOpenDocumentPaths(cwd) {
|
|
1943
|
+
const paths = new Set();
|
|
1944
|
+
for (const { client } of this.activeClientsForCwd(cwd)) {
|
|
1945
|
+
try {
|
|
1946
|
+
for (const filePath of client.openDocumentPaths?.() ?? [])
|
|
1947
|
+
paths.add(filePath);
|
|
1948
|
+
}
|
|
1949
|
+
catch {
|
|
1950
|
+
// A client torn down mid-enumeration contributes nothing; the sweep
|
|
1951
|
+
// is advisory and must never break on a dying server.
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return [...paths];
|
|
1955
|
+
}
|
|
1610
1956
|
/**
|
|
1611
1957
|
* Get or create LSP client for a file
|
|
1612
1958
|
* Prevents duplicate client creation via in-flight promise tracking
|
|
@@ -1617,14 +1963,76 @@ export class LSPService {
|
|
|
1617
1963
|
// Primary selection considers language servers only — auxiliary servers
|
|
1618
1964
|
// (opengrep, …) attach alongside the primary and are never chosen as it.
|
|
1619
1965
|
const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary");
|
|
1620
|
-
|
|
1966
|
+
// #2525: candidate roots are resolved LAZILY, and at most once per call.
|
|
1967
|
+
// They are deliberately NOT hoisted: `NearestRoot` (clients/lsp/server.ts)
|
|
1968
|
+
// caches successful hits but never negatives, so a fallback that cannot
|
|
1969
|
+
// root here — DenoServer (`fallbackFor: "typescript"`, gated on
|
|
1970
|
+
// deno.json(c)) in a project with no deno.json — re-walks to the
|
|
1971
|
+
// filesystem root on EVERY call, by design, so that a deno.json
|
|
1972
|
+
// scaffolded mid-session is picked up without a restart. Hoisting would
|
|
1973
|
+
// charge that walk to every touch of every .ts file in every non-Deno
|
|
1974
|
+
// project. The sequential trial loop below needs no roots of its own
|
|
1975
|
+
// (`ensureClientForServer` resolves and bails at `!root` before any spawn
|
|
1976
|
+
// work), so a served touch — the hot path — resolves nothing extra at
|
|
1977
|
+
// all. Only the branches that EMIT a candidate list ask, and all of them
|
|
1978
|
+
// are cold paths.
|
|
1979
|
+
const rootMemo = new Map();
|
|
1980
|
+
/**
|
|
1981
|
+
* Split the candidates into the ones that could actually have been waited
|
|
1982
|
+
* on and the ones that never had a spawn slot. A record's subject must be
|
|
1983
|
+
* its producer (#1550) — the #2524 shape.
|
|
1984
|
+
*
|
|
1985
|
+
* #2525 round 3 (F3): no SHIPPED `server.root` rejects today — every one
|
|
1986
|
+
* bottoms out in `markerExists`, which only ever resolves `false` on a
|
|
1987
|
+
* miss — so this was reasoned "removed by construction". A reviewer probe
|
|
1988
|
+
* disproved that: a `root()` that rejects makes this `Promise.all` reject,
|
|
1989
|
+
* which throws out of `getClientForFile` itself (the caller never gets
|
|
1990
|
+
* `undefined` back, and `lsp_client_wait_timeout` is never emitted). A
|
|
1991
|
+
* per-server catch degrades that candidate to unrooted instead of taking
|
|
1992
|
+
* the whole partition down — the same "producer" the memo already knows
|
|
1993
|
+
* (a failed root resolution never got a client either), now WITH the
|
|
1994
|
+
* failure reason so the record says why, not just that.
|
|
1995
|
+
*/
|
|
1996
|
+
const partitionCandidates = async () => {
|
|
1997
|
+
const resolved = await Promise.all(servers.map(async (server) => {
|
|
1998
|
+
try {
|
|
1999
|
+
return {
|
|
2000
|
+
server,
|
|
2001
|
+
root: await this.resolveServerRootOnce(server, filePath, rootMemo),
|
|
2002
|
+
reason: undefined,
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
catch (error) {
|
|
2006
|
+
return {
|
|
2007
|
+
server,
|
|
2008
|
+
root: undefined,
|
|
2009
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
}));
|
|
2013
|
+
const unrootedReasons = {};
|
|
2014
|
+
for (const entry of resolved) {
|
|
2015
|
+
if (entry.root === undefined && entry.reason !== undefined) {
|
|
2016
|
+
unrootedReasons[entry.server.id] = entry.reason;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
return {
|
|
2020
|
+
rooted: resolved
|
|
2021
|
+
.filter((entry) => entry.root !== undefined)
|
|
2022
|
+
.map(({ server, root }) => ({ server, root })),
|
|
2023
|
+
unrootedIds: resolved
|
|
2024
|
+
.filter((entry) => entry.root === undefined)
|
|
2025
|
+
.map((entry) => entry.server.id),
|
|
2026
|
+
unrootedReasons,
|
|
2027
|
+
};
|
|
2028
|
+
};
|
|
1621
2029
|
// hardCapMs is a caller-imposed ceiling (e.g. pipeline budget) that
|
|
1622
2030
|
// prevents tool_result from blocking the TUI for the full LSP cold-start
|
|
1623
2031
|
// window. When no server config sets a wait (serverWaitOverrideMs = 0),
|
|
1624
2032
|
// hardCapMs is used directly — Math.min(0, cap) = 0 would otherwise
|
|
1625
2033
|
// take the no-timeout branch and block indefinitely (e.g. pyright, which
|
|
1626
2034
|
// has no clientWaitTimeoutMs but can take 30s to initialize on cold start).
|
|
1627
|
-
const serverBaseMs =
|
|
2035
|
+
const serverBaseMs = primaryServerWaitFloorMs(filePath, maxWaitMs);
|
|
1628
2036
|
const effectiveMaxWaitMs = hardCapMs !== undefined
|
|
1629
2037
|
? serverBaseMs > 0
|
|
1630
2038
|
? Math.min(serverBaseMs, hardCapMs)
|
|
@@ -1655,7 +2063,10 @@ export class LSPService {
|
|
|
1655
2063
|
// joined it, instead of pinning a starter label on every joiner.
|
|
1656
2064
|
let erroredServerId;
|
|
1657
2065
|
let erroredOutcome;
|
|
1658
|
-
// Try each matching server
|
|
2066
|
+
// Try each matching server in registration order.
|
|
2067
|
+
// `ensureClientForServer` resolves the root itself and returns before
|
|
2068
|
+
// any spawn work when there is none, so an unrooted fallback costs
|
|
2069
|
+
// this loop one already-memoized resolution and no spawn slot.
|
|
1659
2070
|
for (const server of servers) {
|
|
1660
2071
|
// A box, not a `let`: control-flow analysis cannot see the callback
|
|
1661
2072
|
// write and would narrow a plain local to its initializer.
|
|
@@ -1664,7 +2075,7 @@ export class LSPService {
|
|
|
1664
2075
|
};
|
|
1665
2076
|
const spawned = await this.ensureClientForServer(filePath, server, resolvedRoots, noteSpawnInFlight, (reported) => {
|
|
1666
2077
|
acquisition.outcome = reported;
|
|
1667
|
-
});
|
|
2078
|
+
}, rootMemo);
|
|
1668
2079
|
if (spawned) {
|
|
1669
2080
|
logLatency({
|
|
1670
2081
|
type: "phase",
|
|
@@ -1710,8 +2121,16 @@ export class LSPService {
|
|
|
1710
2121
|
},
|
|
1711
2122
|
});
|
|
1712
2123
|
}
|
|
2124
|
+
// #2525: reuse the roots the trial loop above already resolved rather
|
|
2125
|
+
// than re-walking with the RAW `server.root`. Two reasons: the raw
|
|
2126
|
+
// call skips the ceiling/session-root gates, so this dedupe key could
|
|
2127
|
+
// disagree with the identity the acquisition path actually used; and
|
|
2128
|
+
// on the unserved path — the only path that reaches here — every
|
|
2129
|
+
// unrooted candidate would pay a second full walk to the filesystem
|
|
2130
|
+
// root, on every touch, before the `unavailableLogged` dedupe even
|
|
2131
|
+
// looked at it.
|
|
1713
2132
|
const unavailable = (await Promise.all(servers.map(async (server) => {
|
|
1714
|
-
const root = await
|
|
2133
|
+
const root = await this.resolveServerRootOnce(server, filePath, rootMemo);
|
|
1715
2134
|
return {
|
|
1716
2135
|
server,
|
|
1717
2136
|
key: `${server.id}:${root ? normalizeMapKey(root) : "<unresolved>"}`,
|
|
@@ -1767,11 +2186,13 @@ export class LSPService {
|
|
|
1767
2186
|
// `inFlight` is cleared in ensureClientForServer's finally block, so a
|
|
1768
2187
|
// settled acquisition can still be present here. Re-read the published
|
|
1769
2188
|
// clients at the decision point; a usable client outranks the sentinel.
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
2189
|
+
// #2525: this is a cold path, so it is where the roots get resolved —
|
|
2190
|
+
// once, memoized with the trial loop's own resolutions. An unrooted
|
|
2191
|
+
// fallback can never have a client keyed under its (non-existent)
|
|
2192
|
+
// root, so it is neither probed here nor named in the record below.
|
|
2193
|
+
const { rooted, unrootedIds, unrootedReasons } = await partitionCandidates();
|
|
2194
|
+
for (const { server, root } of rooted) {
|
|
2195
|
+
const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
|
|
1775
2196
|
if (client?.isAlive())
|
|
1776
2197
|
return { client, info: server };
|
|
1777
2198
|
}
|
|
@@ -1783,16 +2204,46 @@ export class LSPService {
|
|
|
1783
2204
|
durationMs: 0,
|
|
1784
2205
|
metadata: {
|
|
1785
2206
|
maxWaitMs: effectiveMaxWaitMs,
|
|
1786
|
-
serverIds:
|
|
2207
|
+
serverIds: rooted.map(({ server }) => server.id),
|
|
2208
|
+
unrootedCandidates: {
|
|
2209
|
+
count: unrootedIds.length,
|
|
2210
|
+
ids: unrootedIds,
|
|
2211
|
+
...(Object.keys(unrootedReasons).length > 0
|
|
2212
|
+
? { reasons: unrootedReasons }
|
|
2213
|
+
: {}),
|
|
2214
|
+
},
|
|
1787
2215
|
reason: "budget_skipped_known_slow",
|
|
1788
2216
|
},
|
|
1789
2217
|
});
|
|
1790
2218
|
return undefined;
|
|
1791
2219
|
}
|
|
1792
2220
|
if (waitResult === timeoutSentinel) {
|
|
2221
|
+
// #2525: likewise a cold path — resolve here, not up front.
|
|
2222
|
+
//
|
|
2223
|
+
// #2525 round 3 (L2): `partitionCandidates` does UNCACHED negative
|
|
2224
|
+
// root walks (`NearestRoot` never caches misses, by design — see the
|
|
2225
|
+
// comment above `rootMemo`) and this branch only reaches it AFTER
|
|
2226
|
+
// `effectiveMaxWaitMs` has already elapsed. Measured by the reviewer:
|
|
2227
|
+
// 2.06ms native, ~150ms on the #462 slow-FS profile — unbounded on top
|
|
2228
|
+
// of a budget whose entire contract is "return within the cap". Bound
|
|
2229
|
+
// it with a small ceiling of its own (a quarter of the caller's hard
|
|
2230
|
+
// cap when one was given, capped at 250ms) rather than leaving it the
|
|
2231
|
+
// one piece of async work in this function with no bound at all
|
|
2232
|
+
// (the "async work needs both bounds" shape — here there is no
|
|
2233
|
+
// per-call abort signal to race, only the timer bound applies).
|
|
2234
|
+
const partitionBudgetMs = hardCapMs !== undefined ? Math.min(250, hardCapMs / 4) : 250;
|
|
2235
|
+
const partitioned = await withDeadline(partitionCandidates(), {
|
|
2236
|
+
ms: partitionBudgetMs,
|
|
2237
|
+
onTimeout: "undefined",
|
|
2238
|
+
});
|
|
2239
|
+
const { rooted, unrootedIds, unrootedReasons } = partitioned ?? {
|
|
2240
|
+
rooted: [],
|
|
2241
|
+
unrootedIds: [],
|
|
2242
|
+
unrootedReasons: {},
|
|
2243
|
+
};
|
|
1793
2244
|
// Snapshot known client health — scan by serverId prefix (no root needed)
|
|
1794
2245
|
const knownHealth = [...this.state.clients.entries()]
|
|
1795
|
-
.filter(([k]) =>
|
|
2246
|
+
.filter(([k]) => rooted.some(({ server }) => k.startsWith(`${server.id}:`)))
|
|
1796
2247
|
.map(([k, c]) => ({
|
|
1797
2248
|
serverId: k.split(":")[0],
|
|
1798
2249
|
alive: c.isAlive(),
|
|
@@ -1805,9 +2256,23 @@ export class LSPService {
|
|
|
1805
2256
|
durationMs: effectiveMaxWaitMs,
|
|
1806
2257
|
metadata: {
|
|
1807
2258
|
maxWaitMs: effectiveMaxWaitMs,
|
|
1808
|
-
serverIds
|
|
2259
|
+
// `serverIds` names the ROOTED pool the timeout record reports
|
|
2260
|
+
// against, not "servers this call actually waited on" — the
|
|
2261
|
+
// sequential trial loop can time out having reached only the
|
|
2262
|
+
// first of several rooted candidates (case 2 in the regression
|
|
2263
|
+
// test pins both `["typescript","deno"]` even though the loop
|
|
2264
|
+
// never got past `typescript`).
|
|
2265
|
+
serverIds: rooted.map(({ server }) => server.id),
|
|
2266
|
+
unrootedCandidates: {
|
|
2267
|
+
count: unrootedIds.length,
|
|
2268
|
+
ids: unrootedIds,
|
|
2269
|
+
...(Object.keys(unrootedReasons).length > 0
|
|
2270
|
+
? { reasons: unrootedReasons }
|
|
2271
|
+
: {}),
|
|
2272
|
+
},
|
|
1809
2273
|
// servers absent from knownHealth were never spawned or are still spawning
|
|
1810
2274
|
knownClientHealth: knownHealth,
|
|
2275
|
+
...(partitioned === undefined ? { partition: "timed-out" } : {}),
|
|
1811
2276
|
},
|
|
1812
2277
|
});
|
|
1813
2278
|
return undefined;
|
|
@@ -1815,8 +2280,10 @@ export class LSPService {
|
|
|
1815
2280
|
return waitResult;
|
|
1816
2281
|
}
|
|
1817
2282
|
/**
|
|
1818
|
-
* Get or create
|
|
1819
|
-
*
|
|
2283
|
+
* Get or create all complementary LSP clients that can serve a file. Alternate
|
|
2284
|
+
* language servers marked with `fallbackFor` remain sequential fallbacks: an
|
|
2285
|
+
* aggregate diagnostics pass must not launch them beside a working preferred
|
|
2286
|
+
* server merely because it requested `clientScope: "all"`.
|
|
1820
2287
|
*/
|
|
1821
2288
|
async getClientsForFile(filePath, excludeServerIds, resolvedRoots) {
|
|
1822
2289
|
const allServers = getServersForFileWithConfig(filePath);
|
|
@@ -1825,28 +2292,59 @@ export class LSPService {
|
|
|
1825
2292
|
: allServers;
|
|
1826
2293
|
if (servers.length === 0)
|
|
1827
2294
|
return { clients: [], serverCountAttempted: 0 };
|
|
1828
|
-
//
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
2295
|
+
// Resolve once to keep the attempted count tied to servers with a real root.
|
|
2296
|
+
const rootedServers = (await Promise.all(servers.map(async (server) => ({
|
|
2297
|
+
server,
|
|
2298
|
+
root: await this.resolveServerRoot(server, filePath),
|
|
2299
|
+
})))).filter((entry) => entry.root !== undefined);
|
|
2300
|
+
let serverCountAttempted = 0;
|
|
2301
|
+
const acquisitions = new Map();
|
|
2302
|
+
const acquire = (server) => {
|
|
2303
|
+
serverCountAttempted += 1;
|
|
2304
|
+
return this.ensureClientForServer(filePath, server, resolvedRoots);
|
|
1836
2305
|
};
|
|
2306
|
+
// Start complementary servers immediately. An alternate waits only for its
|
|
2307
|
+
// own preferred server, not for unrelated scanners, and starts if that server
|
|
2308
|
+
// declines. Registry order makes chained fallbacks deterministic.
|
|
2309
|
+
for (const { server } of rootedServers) {
|
|
2310
|
+
const preferred = server.fallbackFor
|
|
2311
|
+
? acquisitions.get(server.fallbackFor)
|
|
2312
|
+
: undefined;
|
|
2313
|
+
const acquisition = preferred
|
|
2314
|
+
? preferred.then((entry) => entry === undefined ? acquire(server) : undefined)
|
|
2315
|
+
: acquire(server);
|
|
2316
|
+
acquisitions.set(server.id, acquisition);
|
|
2317
|
+
}
|
|
2318
|
+
const results = await Promise.all(acquisitions.values());
|
|
2319
|
+
const clients = results.filter((entry) => entry !== undefined);
|
|
2320
|
+
return { clients, serverCountAttempted };
|
|
1837
2321
|
}
|
|
1838
2322
|
/**
|
|
1839
2323
|
* Spawn/get the AUXILIARY clients for a file (role:"auxiliary") restricted to
|
|
1840
2324
|
* the enabled set. These attach alongside the primary on the with-auxiliary
|
|
1841
2325
|
* diagnostics path (cross-cutting scanners like opengrep).
|
|
1842
2326
|
*/
|
|
1843
|
-
async getAuxiliaryClientsForFile(filePath, enabledIds, onOutcome) {
|
|
2327
|
+
async getAuxiliaryClientsForFile(filePath, enabledIds, onOutcome, maxWaitMs, signal, hook) {
|
|
1844
2328
|
if (this.checkDestroyed() || enabledIds.size === 0)
|
|
1845
2329
|
return [];
|
|
1846
2330
|
const servers = getServersForFileWithConfig(filePath).filter((s) => s.role === "auxiliary" && enabledIds.has(s.id));
|
|
1847
2331
|
if (servers.length === 0)
|
|
1848
2332
|
return [];
|
|
1849
|
-
const
|
|
2333
|
+
const rootMemo = new Map();
|
|
2334
|
+
const effectiveSignal = signal ?? getAmbientAbortSignal();
|
|
2335
|
+
const effectiveHook = hook ?? "tool_result_edit";
|
|
2336
|
+
const spawned = await Promise.all(servers.map(async (server) => {
|
|
2337
|
+
const acquisition = this.ensureClientForServer(filePath, server, undefined, undefined, (reported) => onOutcome?.(server.id, reported), rootMemo);
|
|
2338
|
+
if (!maxWaitMs || maxWaitMs <= 0) {
|
|
2339
|
+
return acquisition;
|
|
2340
|
+
}
|
|
2341
|
+
return bounded(acquisition, {
|
|
2342
|
+
ms: maxWaitMs,
|
|
2343
|
+
signal: effectiveSignal,
|
|
2344
|
+
hook: effectiveHook,
|
|
2345
|
+
label: `auxiliary-spawn:${server.id}`,
|
|
2346
|
+
});
|
|
2347
|
+
}));
|
|
1850
2348
|
return spawned.filter((entry) => Boolean(entry));
|
|
1851
2349
|
}
|
|
1852
2350
|
/**
|
|
@@ -1928,9 +2426,11 @@ export class LSPService {
|
|
|
1928
2426
|
* the Gate-B readiness check, this NEVER creates or warms a client — it only
|
|
1929
2427
|
* resolves each requested server's root and reads the already-connected
|
|
1930
2428
|
* client's cached diagnostics for `filePath`. Servers with no live client
|
|
1931
|
-
* are
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
2429
|
+
* are absent unless notify-stall teardown marked that generation as
|
|
2430
|
+
* replaceable; that status carries the demotion timestamp so turn-end late
|
|
2431
|
+
* coverage can correlate each pair to the removed generation. A live
|
|
2432
|
+
* client is present even when its per-file cache entry is empty; its
|
|
2433
|
+
* timestamp distinguishes a published clean result from no publication.
|
|
1934
2434
|
*/
|
|
1935
2435
|
async readCachedDiagnosticsForServers(filePath, serverIds) {
|
|
1936
2436
|
const out = new Map();
|
|
@@ -1944,8 +2444,20 @@ export class LSPService {
|
|
|
1944
2444
|
continue;
|
|
1945
2445
|
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
1946
2446
|
const client = this.state.clients.get(key);
|
|
1947
|
-
if (!client?.isAlive())
|
|
2447
|
+
if (!client?.isAlive()) {
|
|
2448
|
+
const demotedAt = this.notifyStallDemotions.get(key);
|
|
2449
|
+
if (demotedAt !== undefined) {
|
|
2450
|
+
out.set(server.id, {
|
|
2451
|
+
diags: [],
|
|
2452
|
+
notifyStallDemoted: true,
|
|
2453
|
+
demotedAt,
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
1948
2456
|
continue;
|
|
2457
|
+
}
|
|
2458
|
+
// A replacement is live again. The old generation's marker no longer
|
|
2459
|
+
// describes this client and must not make a later absence look transient.
|
|
2460
|
+
this.notifyStallDemotions.delete(key);
|
|
1949
2461
|
const entry = client.getAllDiagnostics().get(normalizeMapKey(filePath));
|
|
1950
2462
|
out.set(server.id, {
|
|
1951
2463
|
diags: entry?.diags ?? [],
|
|
@@ -2156,7 +2668,29 @@ export class LSPService {
|
|
|
2156
2668
|
noteSpawnVerdict(key, verdict) {
|
|
2157
2669
|
this.lastSpawnVerdict.set(key, verdict);
|
|
2158
2670
|
}
|
|
2159
|
-
|
|
2671
|
+
/**
|
|
2672
|
+
* {@link resolveServerRoot}, memoized against a caller-owned map so one
|
|
2673
|
+
* acquisition asks a given server for its root at most once (#2525).
|
|
2674
|
+
*
|
|
2675
|
+
* Resolution is cheap exactly when it SUCCEEDS: `NearestRoot` caches hits,
|
|
2676
|
+
* but deliberately never caches negatives (so a marker scaffolded
|
|
2677
|
+
* mid-session is picked up without a restart). An unrooted candidate
|
|
2678
|
+
* therefore re-walks to the filesystem root on every call, which is why the
|
|
2679
|
+
* acquisition path and the records it emits must share one resolution
|
|
2680
|
+
* instead of each taking their own. Without a `memo` this is a plain
|
|
2681
|
+
* passthrough, for callers that have no acquisition to share with.
|
|
2682
|
+
*/
|
|
2683
|
+
resolveServerRootOnce(server, filePath, memo) {
|
|
2684
|
+
if (!memo)
|
|
2685
|
+
return this.resolveServerRoot(server, filePath);
|
|
2686
|
+
const pending = memo.get(server.id);
|
|
2687
|
+
if (pending !== undefined)
|
|
2688
|
+
return pending;
|
|
2689
|
+
const started = this.resolveServerRoot(server, filePath);
|
|
2690
|
+
memo.set(server.id, started);
|
|
2691
|
+
return started;
|
|
2692
|
+
}
|
|
2693
|
+
async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight, onOutcome, rootMemo) {
|
|
2160
2694
|
const handoff = this.generationHandoff;
|
|
2161
2695
|
if (handoff) {
|
|
2162
2696
|
await handoff;
|
|
@@ -2166,7 +2700,7 @@ export class LSPService {
|
|
|
2166
2700
|
if (this.checkDestroyed())
|
|
2167
2701
|
return undefined;
|
|
2168
2702
|
}
|
|
2169
|
-
const root = await this.
|
|
2703
|
+
const root = await this.resolveServerRootOnce(server, filePath, rootMemo);
|
|
2170
2704
|
if (!root || this.checkDestroyed())
|
|
2171
2705
|
return undefined;
|
|
2172
2706
|
if (server.role !== "auxiliary") {
|
|
@@ -2269,6 +2803,7 @@ export class LSPService {
|
|
|
2269
2803
|
intentional: wasIntentional,
|
|
2270
2804
|
},
|
|
2271
2805
|
});
|
|
2806
|
+
this.releaseOutstandingAuxNotifyWrite(key);
|
|
2272
2807
|
try {
|
|
2273
2808
|
await existing.shutdown();
|
|
2274
2809
|
}
|
|
@@ -2593,6 +3128,12 @@ export class LSPService {
|
|
|
2593
3128
|
}
|
|
2594
3129
|
const override = getServerInitOverride(server.id, filePath);
|
|
2595
3130
|
const mergedInit = mergeInitializationOptions(spawned.initialization, override?.initializationOptions);
|
|
3131
|
+
// A replacement process is a new first-contact identity even when the
|
|
3132
|
+
// configured server id is unchanged. Re-arm both capability latches before
|
|
3133
|
+
// the replacement can publish a snapshot.
|
|
3134
|
+
this.state.diagnosticsPublished.delete(server.id);
|
|
3135
|
+
this.state.diagnosticsUnsupported.delete(server.id);
|
|
3136
|
+
this.firstContactWaits.delete(server.id);
|
|
2596
3137
|
const client = await createLSPClient({
|
|
2597
3138
|
serverId: server.id,
|
|
2598
3139
|
process: spawned.process,
|
|
@@ -2601,6 +3142,9 @@ export class LSPService {
|
|
|
2601
3142
|
initialization: mergedInit,
|
|
2602
3143
|
initializeTimeoutMs: server.initializeTimeoutMs,
|
|
2603
3144
|
launchVariant: spawned.launchVariant,
|
|
3145
|
+
onDiagnosticsPublished: (serverId) => {
|
|
3146
|
+
this.state.diagnosticsPublished.add(serverId);
|
|
3147
|
+
},
|
|
2604
3148
|
});
|
|
2605
3149
|
// Guard 2: service was shut down while we were completing the initialize
|
|
2606
3150
|
// handshake. Shut down the live client best-effort and do not register it.
|
|
@@ -2618,6 +3162,9 @@ export class LSPService {
|
|
|
2618
3162
|
diagnosticProviderKind: "unavailable",
|
|
2619
3163
|
};
|
|
2620
3164
|
this.state.clients.set(key, client);
|
|
3165
|
+
// #2356: this generation is the replacement the late-coverage probe was
|
|
3166
|
+
// waiting for. Clear the retired-generation marker before any later probe.
|
|
3167
|
+
this.notifyStallDemotions.delete(key);
|
|
2621
3168
|
this.unavailableLogged.delete(key);
|
|
2622
3169
|
// #1934 review F1: a success retires the previous verdict, so the map
|
|
2623
3170
|
// never outlives the attempts it describes.
|
|
@@ -2759,6 +3306,11 @@ export class LSPService {
|
|
|
2759
3306
|
return;
|
|
2760
3307
|
}
|
|
2761
3308
|
const startedAt = Date.now();
|
|
3309
|
+
const hookDeadlineAt = options.hook !== undefined &&
|
|
3310
|
+
Object.hasOwn(HOOK_WALL_BUDGET_MS, options.hook)
|
|
3311
|
+
? startedAt +
|
|
3312
|
+
HOOK_WALL_BUDGET_MS[options.hook]
|
|
3313
|
+
: undefined;
|
|
2762
3314
|
const normalizedPath = normalizeMapKey(filePath);
|
|
2763
3315
|
const outsideRoot = await this.findOutsideProjectRoot(filePath);
|
|
2764
3316
|
if (outsideRoot) {
|
|
@@ -2800,10 +3352,27 @@ export class LSPService {
|
|
|
2800
3352
|
else if (clientScope === "with-auxiliary") {
|
|
2801
3353
|
// Primary language server + the enabled cross-cutting auxiliaries
|
|
2802
3354
|
// (opengrep, …). The aggregation layer merges/dedups their diagnostics.
|
|
3355
|
+
const auxStartedAt = Date.now();
|
|
2803
3356
|
const [entry, aux] = await Promise.all([
|
|
2804
3357
|
this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons),
|
|
2805
|
-
this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? []), noteColdAuxiliary),
|
|
3358
|
+
this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? []), noteColdAuxiliary, options.maxClientWaitMs, undefined, options.hook),
|
|
2806
3359
|
]);
|
|
3360
|
+
const auxDurationMs = Date.now() - auxStartedAt;
|
|
3361
|
+
if (options.auxiliaryServerIds && options.auxiliaryServerIds.length > 0) {
|
|
3362
|
+
logLatency({
|
|
3363
|
+
type: "phase",
|
|
3364
|
+
phase: "auxiliary_readiness",
|
|
3365
|
+
filePath: normalizedPath,
|
|
3366
|
+
durationMs: auxDurationMs,
|
|
3367
|
+
metadata: {
|
|
3368
|
+
serverIds: aux.map((s) => s.info.id),
|
|
3369
|
+
attemptedCount: options.auxiliaryServerIds.length,
|
|
3370
|
+
readyCount: aux.length,
|
|
3371
|
+
coldServerIds: [...coldAuxiliaryServerIds],
|
|
3372
|
+
source,
|
|
3373
|
+
},
|
|
3374
|
+
});
|
|
3375
|
+
}
|
|
2807
3376
|
spawned = entry ? [entry, ...aux] : aux;
|
|
2808
3377
|
serverCountAttempted = spawned.length;
|
|
2809
3378
|
}
|
|
@@ -2850,6 +3419,19 @@ export class LSPService {
|
|
|
2850
3419
|
if (!leaseKeys) {
|
|
2851
3420
|
// An idle eviction won between selection and lease admission. Resolve the
|
|
2852
3421
|
// now-current client set and replay; no notification targets the retiree.
|
|
3422
|
+
if (this.checkDestroyed()) {
|
|
3423
|
+
const primaryServerIds = spawned
|
|
3424
|
+
.filter((entry) => entry.info.role !== "auxiliary")
|
|
3425
|
+
.map((entry) => entry.info.id);
|
|
3426
|
+
return {
|
|
3427
|
+
diags: [],
|
|
3428
|
+
inconclusive: true,
|
|
3429
|
+
...(primaryServerIds.length > 0 && {
|
|
3430
|
+
inconclusiveServerIds: primaryServerIds,
|
|
3431
|
+
}),
|
|
3432
|
+
inconclusiveReason: "diagnostics-wait",
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
2853
3435
|
return this.touchFile(filePath, content, options);
|
|
2854
3436
|
}
|
|
2855
3437
|
try {
|
|
@@ -3008,14 +3590,26 @@ export class LSPService {
|
|
|
3008
3590
|
if (!notifySkipped) {
|
|
3009
3591
|
const budget = notifyWriteBudgetMs();
|
|
3010
3592
|
// #1459: how long a queued auxiliary may wait for its resync slot. Bounded
|
|
3011
|
-
// by the write budget
|
|
3012
|
-
//
|
|
3013
|
-
//
|
|
3593
|
+
// by the write budget and by the effective primary wait floor, minus what
|
|
3594
|
+
// which includes the caller's `maxClientWaitMs` and any primary
|
|
3595
|
+
// server `clientWaitTimeoutMs` override. The elapsed client wait is
|
|
3596
|
+
// subtracted below.
|
|
3014
3597
|
// A flat write-budget wait would tax a caller that asked for less than one
|
|
3015
3598
|
// budget in total. Non-positive means "no time left to queue": the server
|
|
3016
3599
|
// is reported as uncovered immediately.
|
|
3600
|
+
//
|
|
3601
|
+
// #2239: "what the caller already declared" is `primaryServerWaitFloorMs`,
|
|
3602
|
+
// not the raw `options.maxClientWaitMs` — the SAME floor `getClientForFile`
|
|
3603
|
+
// races the primary spawn against. A cold primary configured with its own
|
|
3604
|
+
// `clientWaitTimeoutMs` (Ruby 30s, and the #2233 Bash/JSON/Vue/Svelte/Prisma
|
|
3605
|
+
// overrides) is allowed to run past the caller's flat budget, and did just
|
|
3606
|
+
// that by the time this line runs — charging the subtraction against the
|
|
3607
|
+
// flat value alone always went negative and clamped to zero. The outer
|
|
3608
|
+
// `Math.min(budget, …)` is unchanged, so this raises what "already spent"
|
|
3609
|
+
// is measured against without widening the wait beyond one write budget.
|
|
3017
3610
|
const queueWaitMs = options.maxClientWaitMs !== undefined
|
|
3018
|
-
? Math.min(budget, Math.max(0, options.maxClientWaitMs
|
|
3611
|
+
? Math.min(budget, Math.max(0, primaryServerWaitFloorMs(filePath, options.maxClientWaitMs) -
|
|
3612
|
+
(Date.now() - startedAt)))
|
|
3019
3613
|
: budget;
|
|
3020
3614
|
await Promise.all(spawned.map(async (entry) => {
|
|
3021
3615
|
// #743: this server already has this content from a recent touch
|
|
@@ -3103,10 +3697,13 @@ export class LSPService {
|
|
|
3103
3697
|
// streak outright, so only the late case retracts. A landing
|
|
3104
3698
|
// past the WEDGE window keeps its strike: at that point the
|
|
3105
3699
|
// stall was long enough that #743's demotion is the honest
|
|
3106
|
-
// verdict, not a latency artifact.
|
|
3700
|
+
// verdict, not a latency artifact. #2358: the wedge window
|
|
3701
|
+
// is the adaptive one the timer itself arms with, so a
|
|
3702
|
+
// write a BUSY server would have been left alone for also
|
|
3703
|
+
// retracts its strike instead of accruing a ladder charge.
|
|
3107
3704
|
const outstandingMs = Date.now() - writeStartedAt;
|
|
3108
3705
|
if (outstandingMs > budget &&
|
|
3109
|
-
outstandingMs <=
|
|
3706
|
+
outstandingMs <= this.auxNotifyWedgeBudgetMs(clientKey)) {
|
|
3110
3707
|
this.retractNotifyWriteBackpressure(clientKey, entry.info.id, filePath, outstandingMs, client);
|
|
3111
3708
|
}
|
|
3112
3709
|
}, release);
|
|
@@ -3232,6 +3829,7 @@ export class LSPService {
|
|
|
3232
3829
|
// fallback below may still fill it in on a timed-out empty result).
|
|
3233
3830
|
let tsserverSyncEligible = false;
|
|
3234
3831
|
let tsserverSyncConfirmed;
|
|
3832
|
+
let diagnosticsUnsupportedServerIds = [];
|
|
3235
3833
|
if (diagnosticsMode !== "none") {
|
|
3236
3834
|
// Resolution: env wins so users can tune the cap without rebuilding.
|
|
3237
3835
|
// Otherwise, on the single-server hot path (primary scope), use that
|
|
@@ -3302,6 +3900,9 @@ export class LSPService {
|
|
|
3302
3900
|
root: entry.client.root,
|
|
3303
3901
|
operationSupport: entry.client.getOperationSupport(),
|
|
3304
3902
|
workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
|
|
3903
|
+
customServer: entry.info.custom,
|
|
3904
|
+
diagnosticsPublished: this.state.diagnosticsPublished.has(entry.info.id),
|
|
3905
|
+
diagnosticsUnsupported: this.state.diagnosticsUnsupported.has(entry.info.id),
|
|
3305
3906
|
advertisedCommands: entry.client.getAdvertisedCommands(),
|
|
3306
3907
|
rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
|
|
3307
3908
|
launchVariant: entry.client.getLaunchVariant?.(),
|
|
@@ -3445,6 +4046,29 @@ export class LSPService {
|
|
|
3445
4046
|
const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
|
|
3446
4047
|
options.collectDiagnostics === true &&
|
|
3447
4048
|
spawned.some((e) => e.info.role === "auxiliary");
|
|
4049
|
+
if (spawned.some((entry) => entry.info.custom === true)) {
|
|
4050
|
+
try {
|
|
4051
|
+
const snapshotHook = options.hook ?? "tool_result_edit";
|
|
4052
|
+
const snapshotBudget = hookDeadlineAt === undefined
|
|
4053
|
+
? HOOK_WALL_BUDGET_MS.tool_result_edit
|
|
4054
|
+
: Math.max(0, hookDeadlineAt - Date.now());
|
|
4055
|
+
const snapshots = await bounded(this.getCapabilitySnapshots(filePath), {
|
|
4056
|
+
ms: snapshotBudget,
|
|
4057
|
+
signal: getAmbientAbortSignal(),
|
|
4058
|
+
shutdownSignal: this.shutdownController.signal,
|
|
4059
|
+
hook: snapshotHook,
|
|
4060
|
+
label: `getCapabilitySnapshots:${filePath}`,
|
|
4061
|
+
});
|
|
4062
|
+
if (snapshots !== undefined) {
|
|
4063
|
+
diagnosticsUnsupportedServerIds = spawned
|
|
4064
|
+
.filter((entry) => classifyServerWaitTier(entry.info.id, snapshots.find((s) => s.serverId === entry.info.id)) === "diagnostics-unsupported")
|
|
4065
|
+
.map((entry) => entry.info.id);
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
catch {
|
|
4069
|
+
// Capability uncertainty fails closed and retains the wait.
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
3448
4072
|
// Per-server wait promises (each already bounded by its own
|
|
3449
4073
|
// perServerTimeout — unchanged from before R8).
|
|
3450
4074
|
let pressureSnapshots = [];
|
|
@@ -3469,12 +4093,33 @@ export class LSPService {
|
|
|
3469
4093
|
? auxWaitBudgetMs(entry.client.serverId, true, configuredAuxCeilingMs, declaredServerTimeout)
|
|
3470
4094
|
: perServerDeclaredTimeouts[entryIndex];
|
|
3471
4095
|
});
|
|
4096
|
+
const firstContactCutOffServerIds = new Set();
|
|
4097
|
+
const firstContactErroredServerIds = new Set();
|
|
4098
|
+
const answeredForThisTouch = (entry) => {
|
|
4099
|
+
const baseline = diagnosticBaselines.get(entry.client);
|
|
4100
|
+
const currentPathVersion = readPathVersion(entry.client);
|
|
4101
|
+
if (Number.isFinite(baseline) &&
|
|
4102
|
+
currentPathVersion !== undefined &&
|
|
4103
|
+
currentPathVersion > baseline) {
|
|
4104
|
+
return true;
|
|
4105
|
+
}
|
|
4106
|
+
try {
|
|
4107
|
+
return (entry.client.getAllDiagnostics?.().has(normalizedPath) === true);
|
|
4108
|
+
}
|
|
4109
|
+
catch {
|
|
4110
|
+
return false;
|
|
4111
|
+
}
|
|
4112
|
+
};
|
|
3472
4113
|
const perServerRaceBudgets = spawned.map((entry, entryIndex) => {
|
|
3473
4114
|
return hasTouchAuxiliaries && entry.info.role === "auxiliary"
|
|
3474
4115
|
? auxWaitBudgetMs(entry.client.serverId, coldAuxiliaryServerIds.has(entry.client.serverId), configuredAuxCeilingMs, perServerDeclaredTimeouts[entryIndex])
|
|
3475
4116
|
: perServerDeclaredTimeouts[entryIndex];
|
|
3476
4117
|
});
|
|
3477
4118
|
const perServerWaits = spawned.map((entry, entryIndex) => {
|
|
4119
|
+
if (this.state.diagnosticsUnsupported.has(entry.info.id) ||
|
|
4120
|
+
diagnosticsUnsupportedServerIds.includes(entry.info.id)) {
|
|
4121
|
+
return Promise.resolve(undefined);
|
|
4122
|
+
}
|
|
3478
4123
|
// #1459: a DEFERRED server never received this content, so its version
|
|
3479
4124
|
// can never advance past the baseline — waiting on it burns its whole
|
|
3480
4125
|
// budget and would flip the touch to `inconclusive`, discarding a
|
|
@@ -3504,7 +4149,7 @@ export class LSPService {
|
|
|
3504
4149
|
const isWarmupTouch = source === "lsp_sweep_warmup";
|
|
3505
4150
|
// #743: per-server — a server we DID push to still gets the
|
|
3506
4151
|
// version-baseline wait even when a sibling was debounced away.
|
|
3507
|
-
const
|
|
4152
|
+
const waitForDiagnostics = () => !notifySkippedServerIds.has(entry.info.id) &&
|
|
3508
4153
|
Number.isFinite(baseline)
|
|
3509
4154
|
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
3510
4155
|
minVersion: baseline,
|
|
@@ -3521,7 +4166,73 @@ export class LSPService {
|
|
|
3521
4166
|
pullSettleSource: "pull-warmup",
|
|
3522
4167
|
})
|
|
3523
4168
|
: entry.client.waitForDiagnostics(filePath, serverTimeout);
|
|
3524
|
-
|
|
4169
|
+
const firstContact = entry.info.custom === true &&
|
|
4170
|
+
!this.state.diagnosticsPublished.has(entry.info.id) &&
|
|
4171
|
+
!this.state.diagnosticsUnsupported.has(entry.info.id) &&
|
|
4172
|
+
entry.client.getWorkspaceDiagnosticsSupport().mode !== "pull";
|
|
4173
|
+
if (!firstContact)
|
|
4174
|
+
return waitForDiagnostics().catch(() => undefined);
|
|
4175
|
+
let shared = this.firstContactWaits.get(entry.info.id);
|
|
4176
|
+
if (!shared) {
|
|
4177
|
+
let removeShutdownListener;
|
|
4178
|
+
const shutdown = new Promise((resolve) => {
|
|
4179
|
+
const onShutdown = () => resolve("shutdown");
|
|
4180
|
+
if (this.shutdownController.signal.aborted) {
|
|
4181
|
+
onShutdown();
|
|
4182
|
+
return;
|
|
4183
|
+
}
|
|
4184
|
+
this.shutdownController.signal.addEventListener("abort", onShutdown, {
|
|
4185
|
+
once: true,
|
|
4186
|
+
});
|
|
4187
|
+
removeShutdownListener = () => this.shutdownController.signal.removeEventListener("abort", onShutdown);
|
|
4188
|
+
});
|
|
4189
|
+
const probe = Promise.race([
|
|
4190
|
+
waitForDiagnostics().then(() => "completed", () => "errored"),
|
|
4191
|
+
shutdown,
|
|
4192
|
+
]);
|
|
4193
|
+
const completed = withDeadline(probe, {
|
|
4194
|
+
ms: serverTimeout,
|
|
4195
|
+
onTimeout: "undefined",
|
|
4196
|
+
});
|
|
4197
|
+
shared = completed
|
|
4198
|
+
.then((outcome) => {
|
|
4199
|
+
if (outcome === "errored" || outcome === "shutdown") {
|
|
4200
|
+
return "errored";
|
|
4201
|
+
}
|
|
4202
|
+
if (answeredForThisTouch(entry))
|
|
4203
|
+
return "answered";
|
|
4204
|
+
this.state.diagnosticsUnsupported.add(entry.info.id);
|
|
4205
|
+
recordDegradationOnce({
|
|
4206
|
+
kind: "lsp-diagnostics-unsupported",
|
|
4207
|
+
subject: entry.info.id,
|
|
4208
|
+
reason: "custom server has no pull provider and no publish evidence after first contact wait",
|
|
4209
|
+
});
|
|
4210
|
+
return "silent";
|
|
4211
|
+
})
|
|
4212
|
+
.catch(() => "errored")
|
|
4213
|
+
.finally(() => {
|
|
4214
|
+
removeShutdownListener?.();
|
|
4215
|
+
if (this.firstContactWaits.get(entry.info.id) === shared) {
|
|
4216
|
+
this.firstContactWaits.delete(entry.info.id);
|
|
4217
|
+
}
|
|
4218
|
+
});
|
|
4219
|
+
this.firstContactWaits.set(entry.info.id, shared);
|
|
4220
|
+
}
|
|
4221
|
+
const callerWait = hookDeadlineAt === undefined
|
|
4222
|
+
? shared
|
|
4223
|
+
: withDeadline(shared, {
|
|
4224
|
+
ms: Math.max(0, hookDeadlineAt - Date.now()),
|
|
4225
|
+
onTimeout: "undefined",
|
|
4226
|
+
});
|
|
4227
|
+
return callerWait.then((outcome) => {
|
|
4228
|
+
if (outcome === undefined) {
|
|
4229
|
+
firstContactCutOffServerIds.add(entry.info.id);
|
|
4230
|
+
}
|
|
4231
|
+
else if (outcome === "errored") {
|
|
4232
|
+
firstContactErroredServerIds.add(entry.info.id);
|
|
4233
|
+
}
|
|
4234
|
+
return undefined;
|
|
4235
|
+
});
|
|
3525
4236
|
});
|
|
3526
4237
|
// The push wait — same per-server budget composition as before #707;
|
|
3527
4238
|
// only the awaiting changed (assigned so it can be raced below).
|
|
@@ -3698,9 +4409,14 @@ export class LSPService {
|
|
|
3698
4409
|
});
|
|
3699
4410
|
})()
|
|
3700
4411
|
: Promise.all(perServerWaits).then(() => { });
|
|
3701
|
-
pushWait
|
|
4412
|
+
// Mark on BOTH paths: a rejected `pushWait` is settled too, and a
|
|
4413
|
+
// resolve-only `.then` left a derived promise that rejected unhandled
|
|
4414
|
+
// whenever a per-server wait failed (oxlint no-floating-promises,
|
|
4415
|
+
// 2026-09-07). The rejection itself still reaches the awaiters below.
|
|
4416
|
+
const markPushWaitSettled = () => {
|
|
3702
4417
|
pushWaitSettled = true;
|
|
3703
|
-
}
|
|
4418
|
+
};
|
|
4419
|
+
void pushWait.then(markPushWaitSettled, markPushWaitSettled);
|
|
3704
4420
|
if (tsserverSyncEligible) {
|
|
3705
4421
|
// #707 racing variant: rather than burning the full push-wait budget
|
|
3706
4422
|
// on a silent-on-clean server (which by definition never answers on a
|
|
@@ -3772,6 +4488,35 @@ export class LSPService {
|
|
|
3772
4488
|
await pushWait;
|
|
3773
4489
|
}
|
|
3774
4490
|
const waitedMs = Date.now() - waitStartedAt;
|
|
4491
|
+
const firstContactCustomServerIds = spawned
|
|
4492
|
+
.filter((entry) => entry.info.custom === true &&
|
|
4493
|
+
!this.state.diagnosticsPublished.has(entry.info.id) &&
|
|
4494
|
+
!this.state.diagnosticsUnsupported.has(entry.info.id) &&
|
|
4495
|
+
!firstContactCutOffServerIds.has(entry.info.id) &&
|
|
4496
|
+
!firstContactErroredServerIds.has(entry.info.id) &&
|
|
4497
|
+
entry.client.getWorkspaceDiagnosticsSupport().mode !== "pull")
|
|
4498
|
+
.map((entry) => entry.info.id);
|
|
4499
|
+
for (const serverId of firstContactCustomServerIds) {
|
|
4500
|
+
const entry = spawned.find((candidate) => candidate.info.id === serverId);
|
|
4501
|
+
if (!entry || answeredForThisTouch(entry))
|
|
4502
|
+
continue;
|
|
4503
|
+
this.state.diagnosticsUnsupported.add(serverId);
|
|
4504
|
+
diagnosticsUnsupportedServerIds.push(serverId);
|
|
4505
|
+
recordDegradationOnce({
|
|
4506
|
+
kind: "lsp-diagnostics-unsupported",
|
|
4507
|
+
subject: serverId,
|
|
4508
|
+
reason: "custom server has no pull provider and no publish evidence after first contact wait",
|
|
4509
|
+
});
|
|
4510
|
+
}
|
|
4511
|
+
for (const entry of spawned) {
|
|
4512
|
+
if (entry.info.custom === true &&
|
|
4513
|
+
this.state.diagnosticsUnsupported.has(entry.info.id)) {
|
|
4514
|
+
diagnosticsUnsupportedServerIds.push(entry.info.id);
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
diagnosticsUnsupportedServerIds = [
|
|
4518
|
+
...new Set(diagnosticsUnsupportedServerIds),
|
|
4519
|
+
];
|
|
3775
4520
|
// #1533: the same auxiliary coverage evidence for a collecting touch that
|
|
3776
4521
|
// did NOT enter the aux-grace wait — in practice `clientScope: "all"`, the
|
|
3777
4522
|
// batch/directory scan surface. Auxiliaries ARE spawned on that scope
|
|
@@ -3895,7 +4640,8 @@ export class LSPService {
|
|
|
3895
4640
|
},
|
|
3896
4641
|
});
|
|
3897
4642
|
}
|
|
3898
|
-
else if (waitedMs + 20 >= timeoutMs
|
|
4643
|
+
else if (waitedMs + 20 >= timeoutMs ||
|
|
4644
|
+
firstContactErroredServerIds.size > 0) {
|
|
3899
4645
|
// Within ~20 ms of the configured budget we treat it as a timeout;
|
|
3900
4646
|
// the LSP didn't beat the cap. Diagnostics that arrive late still
|
|
3901
4647
|
// land in the client's cache and surface on the next edit.
|
|
@@ -3920,25 +4666,10 @@ export class LSPService {
|
|
|
3920
4666
|
// reads as unanswered, which for a primary is exactly the pre-#1549
|
|
3921
4667
|
// verdict. This block can therefore only ever NARROW an inconclusive
|
|
3922
4668
|
// touch, never create one.
|
|
3923
|
-
const answeredForThisTouch = (entry) => {
|
|
3924
|
-
const baseline = diagnosticBaselines.get(entry.client);
|
|
3925
|
-
const currentPathVersion = readPathVersion(entry.client);
|
|
3926
|
-
if (Number.isFinite(baseline) &&
|
|
3927
|
-
currentPathVersion !== undefined &&
|
|
3928
|
-
currentPathVersion > baseline) {
|
|
3929
|
-
return true;
|
|
3930
|
-
}
|
|
3931
|
-
try {
|
|
3932
|
-
return (entry.client.getAllDiagnostics?.().has(normalizedPath) === true);
|
|
3933
|
-
}
|
|
3934
|
-
catch {
|
|
3935
|
-
// Fail closed: an unreadable cache is not evidence of an answer.
|
|
3936
|
-
return false;
|
|
3937
|
-
}
|
|
3938
|
-
};
|
|
3939
4669
|
// A deferred server was never sent this content and is not waited on, so
|
|
3940
4670
|
// it cannot have "timed out" — it is already reported as a coverage gap.
|
|
3941
|
-
const waited = spawned.filter((entry) => !deferredResyncServerIds.has(entry.info.id)
|
|
4671
|
+
const waited = spawned.filter((entry) => !deferredResyncServerIds.has(entry.info.id) &&
|
|
4672
|
+
!diagnosticsUnsupportedServerIds.includes(entry.info.id));
|
|
3942
4673
|
const unanswered = waited.filter((entry) => !answeredForThisTouch(entry));
|
|
3943
4674
|
diagnosticsUnansweredServerIds = unanswered.map((e) => e.info.id);
|
|
3944
4675
|
diagnosticsUnansweredPrimaryServerIds = unanswered
|
|
@@ -3948,8 +4679,9 @@ export class LSPService {
|
|
|
3948
4679
|
// preserve, so it keeps the pre-#1549 touch-wide verdict rather than
|
|
3949
4680
|
// absolving itself on an auxiliary's evidence.
|
|
3950
4681
|
const hasWaitedPrimary = waited.some((entry) => entry.info.role !== "auxiliary");
|
|
4682
|
+
const hasUnsupportedPrimary = diagnosticsUnsupportedServerIds.some((serverId) => spawned.some((entry) => entry.info.id === serverId && entry.info.role !== "auxiliary"));
|
|
3951
4683
|
diagnosticsTimedOut =
|
|
3952
|
-
!hasWaitedPrimary ||
|
|
4684
|
+
(!hasWaitedPrimary && !hasUnsupportedPrimary) ||
|
|
3953
4685
|
diagnosticsUnansweredPrimaryServerIds.length > 0;
|
|
3954
4686
|
for (const entry of unanswered) {
|
|
3955
4687
|
incrementDegradationCount({
|
|
@@ -4398,9 +5130,7 @@ export class LSPService {
|
|
|
4398
5130
|
reason: reasons.join(", ") || "scanner coverage gap",
|
|
4399
5131
|
});
|
|
4400
5132
|
}
|
|
4401
|
-
|
|
4402
|
-
type: "phase",
|
|
4403
|
-
phase: "lsp_scanner_coverage_gap",
|
|
5133
|
+
emitBounded("lsp_scanner_coverage_gap", `${source}:${normalizedPath}`, {
|
|
4404
5134
|
filePath: normalizedPath,
|
|
4405
5135
|
durationMs: Date.now() - startedAt,
|
|
4406
5136
|
metadata: {
|
|
@@ -4409,10 +5139,10 @@ export class LSPService {
|
|
|
4409
5139
|
...(brokenSkippedServerIds.length > 0 && {
|
|
4410
5140
|
brokenSkippedServerIds,
|
|
4411
5141
|
}),
|
|
4412
|
-
// #1586: the deferrals this touch is actually uncovered for. The
|
|
5142
|
+
// #1586: the deferrals this touch is actually uncovered for. The
|
|
4413
5143
|
// gate action keeps its own record in `lsp_notify_resync_deferred`;
|
|
4414
|
-
// this row exists to prove a blackout, and a scanner already bound
|
|
4415
|
-
// these bytes is not one.
|
|
5144
|
+
// this row exists to prove a blackout, and a scanner already bound
|
|
5145
|
+
// to these bytes is not one.
|
|
4416
5146
|
...(uncoveredDeferredServerIds.length > 0 && {
|
|
4417
5147
|
deferredResyncServerIds: uncoveredDeferredServerIds,
|
|
4418
5148
|
}),
|
|
@@ -4504,6 +5234,11 @@ export class LSPService {
|
|
|
4504
5234
|
// collect, `binding` only for a collecting touch — a non-collecting touch
|
|
4505
5235
|
// keeps resolving `{ diags: [] }`, no flags.
|
|
4506
5236
|
const result = { diags: collected ?? [] };
|
|
5237
|
+
const primaryDiagnosticsUnsupported = diagnosticsUnsupportedServerIds.some((serverId) => spawned.some((entry) => entry.info.id === serverId && entry.info.role !== "auxiliary"));
|
|
5238
|
+
if (diagnosticsUnsupportedServerIds.length > 0) {
|
|
5239
|
+
result.diagnosticsUnsupportedServerIds =
|
|
5240
|
+
diagnosticsUnsupportedServerIds;
|
|
5241
|
+
}
|
|
4507
5242
|
if (collected !== undefined && inconclusive) {
|
|
4508
5243
|
result.inconclusive = true;
|
|
4509
5244
|
// #1549: name the primary that produced the verdict and which deadline it
|
|
@@ -4516,6 +5251,11 @@ export class LSPService {
|
|
|
4516
5251
|
}
|
|
4517
5252
|
result.inconclusiveReason = verdict.inconclusiveReason;
|
|
4518
5253
|
}
|
|
5254
|
+
else if (collected !== undefined && primaryDiagnosticsUnsupported) {
|
|
5255
|
+
// A navigation-only primary has no diagnostic confirmation to report.
|
|
5256
|
+
// Auxiliary coverage cannot turn that capability boundary into a clean
|
|
5257
|
+
// or partial diagnostic verdict.
|
|
5258
|
+
}
|
|
4519
5259
|
else if (collected !== undefined && coverageGap) {
|
|
4520
5260
|
// #1470/#1493: narrowed, not collapsed. Reached for EITHER no-answer
|
|
4521
5261
|
// shape — a cut-off auxiliary or a silent one with nothing published for
|
|
@@ -4526,7 +5266,7 @@ export class LSPService {
|
|
|
4526
5266
|
result.confirmation = "partial";
|
|
4527
5267
|
result.unconfirmedServerIds = [...unconfirmedServerIds];
|
|
4528
5268
|
}
|
|
4529
|
-
else if (collected !== undefined) {
|
|
5269
|
+
else if (collected !== undefined && !primaryDiagnosticsUnsupported) {
|
|
4530
5270
|
// Preserve the lower-level affirmative result across consumers. In
|
|
4531
5271
|
// particular, the silent-clean gates above clear diagnosticsTimedOut only
|
|
4532
5272
|
// after a successful notify and capability-confirmed wait; reclassifying
|
|
@@ -5297,6 +6037,9 @@ export class LSPService {
|
|
|
5297
6037
|
root,
|
|
5298
6038
|
operationSupport: client.getOperationSupport(),
|
|
5299
6039
|
workspaceDiagnosticsSupport: client.getWorkspaceDiagnosticsSupport(),
|
|
6040
|
+
customServer: server.custom,
|
|
6041
|
+
diagnosticsPublished: this.state.diagnosticsPublished.has(server.id),
|
|
6042
|
+
diagnosticsUnsupported: this.state.diagnosticsUnsupported.has(server.id),
|
|
5300
6043
|
advertisedCommands: client.getAdvertisedCommands(),
|
|
5301
6044
|
rawCapabilityKeys: client.getRawCapabilityKeys?.() ?? [],
|
|
5302
6045
|
launchVariant: client.getLaunchVariant?.(),
|
|
@@ -5314,6 +6057,9 @@ export class LSPService {
|
|
|
5314
6057
|
root: client.root,
|
|
5315
6058
|
operationSupport: client.getOperationSupport(),
|
|
5316
6059
|
workspaceDiagnosticsSupport: client.getWorkspaceDiagnosticsSupport(),
|
|
6060
|
+
customServer: this.state.servers.get(serverId)?.custom,
|
|
6061
|
+
diagnosticsPublished: this.state.diagnosticsPublished.has(serverId),
|
|
6062
|
+
diagnosticsUnsupported: this.state.diagnosticsUnsupported.has(serverId),
|
|
5317
6063
|
advertisedCommands: client.getAdvertisedCommands(),
|
|
5318
6064
|
rawCapabilityKeys: client.getRawCapabilityKeys?.() ?? [],
|
|
5319
6065
|
launchVariant: client.getLaunchVariant?.(),
|
|
@@ -5841,6 +6587,23 @@ export class LSPService {
|
|
|
5841
6587
|
// never proved it can answer diagnostics — that's a failed warm-up. A
|
|
5842
6588
|
// server with an unresolvable key never spawns here, so it can't fail this
|
|
5843
6589
|
// way and is excluded (never skipped by the caller for this file).
|
|
6590
|
+
// #2525: same split the readiness logic above already makes — `keys[i]`
|
|
6591
|
+
// is `undefined` exactly when the server resolves no key for this file,
|
|
6592
|
+
// i.e. it will never spawn here (DenoServer without a deno.json). The
|
|
6593
|
+
// `lsp_sweep_warmup_*` records must not claim to have warmed it. Free:
|
|
6594
|
+
// `keys` is already resolved, so this costs no filesystem work.
|
|
6595
|
+
const rootedServerIds = [];
|
|
6596
|
+
const unrootedServerIds = [];
|
|
6597
|
+
for (let i = 0; i < servers.length; i++) {
|
|
6598
|
+
if (keys[i] !== undefined)
|
|
6599
|
+
rootedServerIds.push(servers[i].id);
|
|
6600
|
+
else
|
|
6601
|
+
unrootedServerIds.push(servers[i].id);
|
|
6602
|
+
}
|
|
6603
|
+
const unrootedCandidates = {
|
|
6604
|
+
count: unrootedServerIds.length,
|
|
6605
|
+
ids: unrootedServerIds,
|
|
6606
|
+
};
|
|
5844
6607
|
const stillColdServerIds = () => {
|
|
5845
6608
|
const cold = [];
|
|
5846
6609
|
for (let i = 0; i < servers.length; i++) {
|
|
@@ -5860,7 +6623,12 @@ export class LSPService {
|
|
|
5860
6623
|
phase: "lsp_sweep_warmup_start",
|
|
5861
6624
|
filePath: representativeFile,
|
|
5862
6625
|
durationMs: 0,
|
|
5863
|
-
metadata: {
|
|
6626
|
+
metadata: {
|
|
6627
|
+
serverIds: rootedServerIds,
|
|
6628
|
+
unrootedCandidates,
|
|
6629
|
+
timeoutMs,
|
|
6630
|
+
attempt,
|
|
6631
|
+
},
|
|
5864
6632
|
});
|
|
5865
6633
|
const warmupAttempt = this.touchFile(representativeFile, content, {
|
|
5866
6634
|
diagnostics: "document",
|
|
@@ -5910,7 +6678,8 @@ export class LSPService {
|
|
|
5910
6678
|
filePath: representativeFile,
|
|
5911
6679
|
durationMs: Date.now() - startedAt,
|
|
5912
6680
|
metadata: {
|
|
5913
|
-
serverIds:
|
|
6681
|
+
serverIds: rootedServerIds,
|
|
6682
|
+
unrootedCandidates,
|
|
5914
6683
|
timeoutMs,
|
|
5915
6684
|
attempt,
|
|
5916
6685
|
coldServerIds,
|
|
@@ -6462,7 +7231,7 @@ export class LSPService {
|
|
|
6462
7231
|
// ONE bracket for the whole fan-out, not one per file (#2272 review F1).
|
|
6463
7232
|
// Per-file bracketing was the first shape and it was inert for exactly
|
|
6464
7233
|
// the case above: `phaseFinished` pushes onto a ring capped at
|
|
6465
|
-
// `
|
|
7234
|
+
// `RECENT_PHASE_CAP` (5, clients/latency-logger.ts), while `turn_end`
|
|
6466
7235
|
// reads `getPhaseForWindow` AFTER the sweep returns. A 225-file sweep
|
|
6467
7236
|
// therefore evicted the blocking file's own bracket unless the block
|
|
6468
7237
|
// landed in the last five files, and the survivors were then rejected by
|
|
@@ -6935,6 +7704,10 @@ export class LSPService {
|
|
|
6935
7704
|
if (this.checkDestroyed())
|
|
6936
7705
|
return;
|
|
6937
7706
|
this.isDestroyed = true;
|
|
7707
|
+
this.shutdownController.abort();
|
|
7708
|
+
for (const [key, token] of this.outstandingAuxNotifyWrites) {
|
|
7709
|
+
this.releaseOutstandingAuxNotifyWrite(key, token);
|
|
7710
|
+
}
|
|
6938
7711
|
for (const key of this.typeScriptIdleTimers.keys()) {
|
|
6939
7712
|
this.clearTypeScriptIdleTimer(key);
|
|
6940
7713
|
}
|
|
@@ -6980,6 +7753,7 @@ export class LSPService {
|
|
|
6980
7753
|
});
|
|
6981
7754
|
this.state.clients.clear();
|
|
6982
7755
|
this.state.broken.clear();
|
|
7756
|
+
this.firstContactWaits.clear();
|
|
6983
7757
|
// #1934 review F1: map hygiene alongside the breaker it sits next to.
|
|
6984
7758
|
// Not load-bearing — every read follows its own attempt's write — but a
|
|
6985
7759
|
// verdict for a client generation that no longer exists is dead weight.
|
|
@@ -6987,7 +7761,15 @@ export class LSPService {
|
|
|
6987
7761
|
// #1459: every gated client is gone, so no outstanding-write record can
|
|
6988
7762
|
// describe a live one. The gate's identity check already neutralises a stale
|
|
6989
7763
|
// entry; clearing keeps the map honest rather than relying on that.
|
|
6990
|
-
this.outstandingAuxNotifyWrites
|
|
7764
|
+
for (const [key, token] of this.outstandingAuxNotifyWrites) {
|
|
7765
|
+
this.releaseOutstandingAuxNotifyWrite(key, token);
|
|
7766
|
+
}
|
|
7767
|
+
this.notifyStallDemotions.clear();
|
|
7768
|
+
// #2358: same reasoning — a per-write latency estimate belongs to a client
|
|
7769
|
+
// generation, and every client is gone. `session_start` reaches this
|
|
7770
|
+
// through the service reset, so the adaptive wedge window re-arms with
|
|
7771
|
+
// the session rather than being priced by a previous one's throughput.
|
|
7772
|
+
this.auxNotifyDrainLatencyEwma.clear();
|
|
6991
7773
|
// #1714: same reasoning — a backlog count belongs to a client generation,
|
|
6992
7774
|
// and every client is gone. `session_start` reaches this through the service
|
|
6993
7775
|
// reset, so the pacing state re-arms with the session rather than living for
|
|
@@ -7189,6 +7971,20 @@ export async function notifyExternalFileChange(filePath, type) {
|
|
|
7189
7971
|
return getLSPService().notifyExternalFileChange(filePath, type);
|
|
7190
7972
|
}
|
|
7191
7973
|
export function resetLSPService(options = {}) {
|
|
7974
|
+
// #2504 review round 2 (F3): whatever the retiring service was still being
|
|
7975
|
+
// asked for off-hook must stop HERE, before any teardown and before the
|
|
7976
|
+
// `!retiringService` early return below (a `session_start` reset with no
|
|
7977
|
+
// live service must still retire a loop the previous session armed). This
|
|
7978
|
+
// is the one choke point every lifecycle path goes through —
|
|
7979
|
+
// `session_shutdown`, `session_start`, and the idle reset — so the
|
|
7980
|
+
// deferred actionable-warnings pull cannot open another file against a
|
|
7981
|
+
// dying service, cannot re-spawn one after the idle reset, and cannot be
|
|
7982
|
+
// mid-`openFile` when the loop closes (#234). Aborting spawns nothing.
|
|
7983
|
+
abortDeferredLspWork(`lsp service reset: ${options.reason ?? "unspecified"}`);
|
|
7984
|
+
// Invalidate availability publication started by the retiring service before
|
|
7985
|
+
// any asynchronous teardown. The launch seam checks this generation after
|
|
7986
|
+
// every managed lookup, install, and process launch (#2351).
|
|
7987
|
+
resetLspLaunchAvailabilityGeneration();
|
|
7192
7988
|
// A new session must get its own classic-tsserver-repair attempt: the
|
|
7193
7989
|
// guard is a process-lifetime flag (see resetClassicTsRepairGuard), so a
|
|
7194
7990
|
// repair that failed transiently in an earlier session must not stay
|