@herbertgao/pi-extensions 2026.8.6 → 2026.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +19 -7
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +30 -9
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +272 -22
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +14 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +55 -17
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
- package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
- package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
- package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +17 -13
- package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
- package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
- package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
- package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
- package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
- package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +21 -6
- package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +20 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +1 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +26 -10
- package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +2 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +3 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/zh.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +3 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +10 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +3 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +30 -7
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +30 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +26 -5
- package/node_modules/@narumitw/pi-btw/README.md +120 -111
- package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
- package/node_modules/@narumitw/pi-btw/package.json +10 -6
- package/node_modules/@narumitw/pi-btw/src/btw.ts +36 -40
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
- package/node_modules/@narumitw/pi-btw/src/menu.ts +45 -2
- package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
- package/node_modules/pi-lens/CHANGELOG.md +909 -0
- package/node_modules/pi-lens/README.md +11 -3
- package/node_modules/pi-lens/config/ruff/core.toml +42 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +126 -13
- package/node_modules/pi-lens/dist/clients/agent-nudge.js +67 -11
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
- package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +285 -8
- package/node_modules/pi-lens/dist/clients/biome-client.js +61 -30
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
- package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +119 -0
- package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
- package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +192 -0
- package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
- package/node_modules/pi-lens/dist/clients/cascade-format.js +20 -1
- package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
- package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
- package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +123 -31
- package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
- package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +62 -13
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +25 -3
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +147 -33
- package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +52 -2
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +256 -88
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +60 -53
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +23 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +14 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +20 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +22 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +10 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +18 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +275 -13
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +21 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +734 -64
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +72 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +9 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +9 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
- package/node_modules/pi-lens/dist/clients/extension-log.js +22 -1
- package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +294 -0
- package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
- package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
- package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
- package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
- package/node_modules/pi-lens/dist/clients/git-guard.js +31 -0
- package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +109 -9
- package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
- package/node_modules/pi-lens/dist/clients/host-ports.js +8 -0
- package/node_modules/pi-lens/dist/clients/installer/index.js +1114 -100
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
- package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +82 -6
- package/node_modules/pi-lens/dist/clients/knip-client.js +310 -10
- package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +2 -0
- package/node_modules/pi-lens/dist/clients/latency-logger.js +440 -11
- package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
- package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +102 -7
- package/node_modules/pi-lens/dist/clients/lsp/client.js +1516 -159
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +83 -1
- package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
- package/node_modules/pi-lens/dist/clients/lsp/index.js +2274 -147
- package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
- package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
- package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
- package/node_modules/pi-lens/dist/clients/lsp/server.js +277 -16
- package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +79 -3
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +43 -0
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
- package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
- package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
- package/node_modules/pi-lens/dist/clients/mcp/session.js +10 -2
- package/node_modules/pi-lens/dist/clients/module-report.js +8 -0
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
- package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
- package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
- package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +154 -33
- package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +84 -14
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +5 -0
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
- package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
- package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +1 -1
- package/node_modules/pi-lens/dist/clients/read-guard.js +184 -10
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
- package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
- package/node_modules/pi-lens/dist/clients/review-graph/query.js +52 -4
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +96 -7
- package/node_modules/pi-lens/dist/clients/runtime-context.js +8 -1
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +308 -45
- package/node_modules/pi-lens/dist/clients/runtime-session.js +332 -9
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +212 -79
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +575 -67
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
- package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
- package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +157 -24
- package/node_modules/pi-lens/dist/clients/sg-runner.js +116 -22
- package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
- package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
- package/node_modules/pi-lens/dist/clients/startup-timing.js +19 -1
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +151 -16
- package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
- package/node_modules/pi-lens/dist/clients/tool-policy.js +125 -20
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
- package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
- package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
- package/node_modules/pi-lens/dist/index.js +67738 -55739
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +466 -54
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +132 -43
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
- package/node_modules/pi-lens/docs/agent-guide.md +6 -1
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +515 -506
- package/node_modules/pi-lens/docs/dispositions.md +21 -0
- package/node_modules/pi-lens/docs/features.md +19 -5
- package/node_modules/pi-lens/docs/globalconfig.md +3 -0
- package/node_modules/pi-lens/docs/language-coverage.md +71 -5
- package/node_modules/pi-lens/docs/mcp.md +2 -2
- package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
- package/node_modules/pi-lens/docs/settings.md +19 -1
- package/node_modules/pi-lens/docs/usage.md +1 -0
- package/node_modules/pi-lens/package.json +10 -6
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-js-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/ts-void-zero-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-object-param-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +15 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-compile-call-test.yml +29 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +2 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +2 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +57 -12
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +22 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +23 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +33 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +34 -8
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +6 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +53 -1
- package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
- package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
- package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +2 -0
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +80 -3
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +41 -0
- package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +36 -0
- package/node_modules/pi-mcp-adapter/README.md +47 -5
- package/node_modules/pi-mcp-adapter/cli.js +163 -1
- package/node_modules/pi-mcp-adapter/commands.ts +128 -22
- package/node_modules/pi-mcp-adapter/config.ts +25 -7
- package/node_modules/pi-mcp-adapter/direct-tools.ts +5 -4
- package/node_modules/pi-mcp-adapter/index.ts +219 -32
- package/node_modules/pi-mcp-adapter/init.ts +36 -4
- package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
- package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
- package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
- package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
- package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +32 -21
- package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
- package/node_modules/pi-mcp-adapter/package.json +3 -1
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +31 -37
- package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
- package/node_modules/pi-mcp-adapter/server-manager.ts +140 -2
- package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
- package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
- package/node_modules/pi-mcp-adapter/types.ts +2 -0
- package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
- package/node_modules/pi-mcp-adapter/utils.ts +70 -0
- package/node_modules/pi-web-access/CHANGELOG.md +23 -0
- package/node_modules/pi-web-access/README.md +27 -9
- package/node_modules/pi-web-access/anysearch.ts +4 -2
- package/node_modules/pi-web-access/brave.ts +16 -4
- package/node_modules/pi-web-access/curator-page.ts +6 -3
- package/node_modules/pi-web-access/curator-server.ts +4 -1
- package/node_modules/pi-web-access/exa.ts +18 -7
- package/node_modules/pi-web-access/extract.ts +28 -3
- package/node_modules/pi-web-access/gemini-search.ts +16 -4
- package/node_modules/pi-web-access/index.ts +41 -57
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/page-query.ts +4 -2
- package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
- package/node_modules/pi-web-access/parallel.ts +2 -2
- package/node_modules/pi-web-access/query-rewrite.ts +51 -0
- package/node_modules/pi-web-access/serper.ts +211 -0
- package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
- package/node_modules/pi-web-access/summary-review.ts +44 -22
- package/node_modules/pi-web-access/tavily.ts +16 -4
- package/node_modules/pi-web-access/utils.ts +87 -0
- package/node_modules/pi-web-access/valyu.ts +199 -0
- package/package.json +13 -13
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - Bus event integration
|
|
8
8
|
* - Resource cleanup
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { CASCADE_DIAGNOSTICS_TTL_MS } from "../cascade-types.js";
|
|
11
11
|
import * as nodeFs from "node:fs";
|
|
12
12
|
import fs from "node:fs/promises";
|
|
13
13
|
import path from "node:path";
|
|
@@ -16,25 +16,30 @@ import { getProjectIgnoreMatcher, isExcludedDirName, } from "../file-utils.js";
|
|
|
16
16
|
import { recordLsp } from "../widget-state.js";
|
|
17
17
|
import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
|
|
18
18
|
import { detectFileRole } from "../file-role.js";
|
|
19
|
+
import { emitBounded } from "../bounded-telemetry.js";
|
|
19
20
|
import { logLatency } from "../latency-logger.js";
|
|
20
21
|
import { logSessionStart } from "../sessionstart-logger.js";
|
|
21
22
|
import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
|
|
22
23
|
import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
|
|
23
24
|
import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
|
|
24
|
-
import { withDeadline } from "../deadline-utils.js";
|
|
25
|
+
import { withDeadline, withTimeout } from "../deadline-utils.js";
|
|
26
|
+
import { acquireWorkspaceSweepHold, clearWorkspaceSweepHoldForSessionStart, } from "./workspace-sweep-hold.js";
|
|
27
|
+
import { DocumentDriftTracker, fingerprintDocumentContent, } from "./document-drift.js";
|
|
25
28
|
import { isAtOrAboveHomeDir, isWindowsPath, normalizeMapKey, uriToPath, } from "../path-utils.js";
|
|
26
29
|
import { recordLspMutation, } from "../lsp-mutation.js";
|
|
27
30
|
import { createLSPClient } from "./client.js";
|
|
28
|
-
import { bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, touchCoverageGap, } from "./diagnostic-binding.js";
|
|
31
|
+
import { auxiliaryCoverageGap, bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, hashDiagnosticContent, resolveTouchVerdict, touchCoverageGap, } from "./diagnostic-binding.js";
|
|
29
32
|
import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
|
|
30
33
|
import { getLanguageId } from "./language.js";
|
|
31
|
-
import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
|
|
34
|
+
import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, } from "./server.js";
|
|
32
35
|
import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
|
|
36
|
+
const WORKSPACE_ATTRIBUTION_CLIENT_CAP = 16;
|
|
33
37
|
import { raceToCompletion } from "./aggregation.js";
|
|
34
38
|
import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, validateWorkspaceEdit, } from "./edits.js";
|
|
35
39
|
import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "./workspace-diagnostics-cache.js";
|
|
36
40
|
import { attemptTsserverSyncDiagnostics, } from "./tsserver-sync.js";
|
|
37
41
|
import { isWarmAttached, tryWarmAttachedDiagnostics, } from "../warm-attach.js";
|
|
42
|
+
import { getSuccessfulLspSpawnDurationMs, recordSuccessfulLspSpawn, } from "./spawn-history.js";
|
|
38
43
|
function destinationUriPreservingSpelling(oldUri, oldFilePath, newFilePath) {
|
|
39
44
|
const canonical = pathToFileURL(newFilePath);
|
|
40
45
|
try {
|
|
@@ -160,6 +165,34 @@ const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(0, Number.parseInt(process.env.PI_LE
|
|
|
160
165
|
1500);
|
|
161
166
|
const TOUCH_DEBOUNCE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_TOUCH_DEBOUNCE_MS ?? "1500", 10) ||
|
|
162
167
|
1500);
|
|
168
|
+
// #1621: the rename-propagation notifies (`didClose` ahead of the rename,
|
|
169
|
+
// `workspace/didRenameFiles` after) share the exit-notify defect #1620 fixed —
|
|
170
|
+
// a notify write on a pipe that is not draining neither resolves nor rejects,
|
|
171
|
+
// so the bare `await` was unbounded. Rename propagation is best-effort advice
|
|
172
|
+
// to servers, not a correctness gate, so a wedged client's notify gets its own
|
|
173
|
+
// ceiling and a recorded disposition rather than stalling the `Promise.all`
|
|
174
|
+
// for every healthy client alongside it.
|
|
175
|
+
// #1621 F3: floor at 50ms, not 0 — a negative env value (e.g. "-100") is
|
|
176
|
+
// truthy after `Number.parseInt`, so it survives the `|| 1500` fallback and
|
|
177
|
+
// would otherwise reach `withDeadline`'s `ms <= 0` branch. That branch treats
|
|
178
|
+
// the budget as already expired and settles without ever really attempting
|
|
179
|
+
// the notify — a negative override would silently disable rename propagation
|
|
180
|
+
// instead of merely shortening its budget.
|
|
181
|
+
const RENAME_NOTIFY_TIMEOUT_MS = Math.max(50, Number.parseInt(process.env.PI_LENS_LSP_RENAME_NOTIFY_TIMEOUT_MS ?? "1500", 10) || 1500);
|
|
182
|
+
async function runRenameNotify(send, timeoutMs) {
|
|
183
|
+
try {
|
|
184
|
+
await withTimeout(send(), timeoutMs);
|
|
185
|
+
return { ok: true };
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const timedOut = err instanceof Error && err.message.startsWith("Timeout after");
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
error: err instanceof Error ? err.message : String(err),
|
|
192
|
+
disposition: timedOut ? "timedOut" : "rejected",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
163
196
|
const DEFAULT_LSP_CLIENT_CEILING = 24;
|
|
164
197
|
const DEFAULT_TS_IDLE_EVICT_MS = 20 * 60_000;
|
|
165
198
|
export function getTypeScriptIdleEvictMs() {
|
|
@@ -181,6 +214,24 @@ export function getLspClientCeiling() {
|
|
|
181
214
|
// project load/index before it can usefully answer ANY diagnostics request,
|
|
182
215
|
// not just one file's worth of work. Env-tunable like every other wait budget
|
|
183
216
|
// in this file.
|
|
217
|
+
/**
|
|
218
|
+
* #1783: does this client hold the document open? Tolerates a client that does
|
|
219
|
+
* not implement `isDocumentOpen` (a test double, or a future client shape) by
|
|
220
|
+
* answering "no" — the drift backstop then drops the record rather than
|
|
221
|
+
* resyncing a view it cannot confirm exists.
|
|
222
|
+
*/
|
|
223
|
+
function documentIsOpenOn(client, filePath) {
|
|
224
|
+
const probe = client
|
|
225
|
+
.isDocumentOpen;
|
|
226
|
+
if (typeof probe !== "function")
|
|
227
|
+
return false;
|
|
228
|
+
try {
|
|
229
|
+
return probe.call(client, filePath) === true;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
184
235
|
function warmupTimeoutMs() {
|
|
185
236
|
const raw = Number.parseInt(process.env.PI_LENS_LSP_WARMUP_TIMEOUT_MS ?? "", 10);
|
|
186
237
|
return Number.isFinite(raw) && raw > 0 ? raw : 20_000;
|
|
@@ -254,7 +305,6 @@ const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.
|
|
|
254
305
|
// Once the fastest client has diagnostics, remaining clients get this window before
|
|
255
306
|
// we proceed with whatever results are ready. 0 disables early-unblock.
|
|
256
307
|
const EARLY_UNBLOCK_GRACE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_EARLY_UNBLOCK_GRACE_MS ?? "400", 10) || 400);
|
|
257
|
-
const CASCADE_DIAGNOSTICS_TTL_MS = 240_000;
|
|
258
308
|
function mergeLspDiagnostics(diagnostics) {
|
|
259
309
|
const merged = [];
|
|
260
310
|
const seen = new Set();
|
|
@@ -399,6 +449,40 @@ function notifyWriteBudgetMs() {
|
|
|
399
449
|
const raw = Number(process.env.PI_LENS_LSP_NOTIFY_BUDGET_MS);
|
|
400
450
|
return Number.isFinite(raw) && raw > 0 ? raw : 2000;
|
|
401
451
|
}
|
|
452
|
+
// #1459: how long ONE auxiliary notify write may stay outstanding before the
|
|
453
|
+
// server counts as wedged rather than merely slow. A scanner whose per-file work
|
|
454
|
+
// exceeds the write budget is normal (opengrep routinely needs >2s on a large
|
|
455
|
+
// file) and must not be demoted for it — the gate defers the next write instead.
|
|
456
|
+
// A write still unaccepted after this window is a different animal: nothing is
|
|
457
|
+
// draining that stdin, so the server is demoted through the existing breaker.
|
|
458
|
+
// Expressed as a multiple of the write budget so tuning one moves both.
|
|
459
|
+
const NOTIFY_WEDGED_BUDGET_MULTIPLIER = 5;
|
|
460
|
+
function notifyWedgedMs() {
|
|
461
|
+
return notifyWriteBudgetMs() * NOTIFY_WEDGED_BUDGET_MULTIPLIER;
|
|
462
|
+
}
|
|
463
|
+
// #1714: how many document notifies one auxiliary may hold UNACKNOWLEDGED
|
|
464
|
+
// before the next notify has to prove the server drained its input.
|
|
465
|
+
//
|
|
466
|
+
// #1459's gate bounds CONCURRENT writes to one per auxiliary. That stops a
|
|
467
|
+
// simultaneous fan-out, but a `lens_diagnostics mode=full` sweep is mostly
|
|
468
|
+
// SEQUENTIAL — one file after another inside a server group (#387) — so every
|
|
469
|
+
// write is alone in flight and the gate never engages. Each write still resolves
|
|
470
|
+
// as soon as the pipe accepts the bytes, not when the scanner has read them, so
|
|
471
|
+
// the sweep can hand a single-threaded scanner hundreds of full re-parses faster
|
|
472
|
+
// than it consumes them. ast-grep stalled and had to be force-killed twice in
|
|
473
|
+
// two full-scan exposures. Counting unacknowledged notifies bounds the BACKLOG
|
|
474
|
+
// the sweep is allowed to build, which pipe-level backpressure alone does not.
|
|
475
|
+
const AUX_NOTIFY_INFLIGHT_DEFAULT = 8;
|
|
476
|
+
function auxNotifyInflightLimit(info) {
|
|
477
|
+
const perServer = info.notifyInflightLimit;
|
|
478
|
+
if (typeof perServer === "number" && Number.isFinite(perServer) && perServer > 0) {
|
|
479
|
+
return Math.floor(perServer);
|
|
480
|
+
}
|
|
481
|
+
const raw = Number(process.env.PI_LENS_LSP_AUX_NOTIFY_INFLIGHT);
|
|
482
|
+
return Number.isFinite(raw) && raw > 0
|
|
483
|
+
? Math.floor(raw)
|
|
484
|
+
: AUX_NOTIFY_INFLIGHT_DEFAULT;
|
|
485
|
+
}
|
|
402
486
|
// Budget for one project-wide `workspace/diagnostic` pull (#387 Item 2). Larger
|
|
403
487
|
// than a per-file wait — it's a single request but scans the whole program —
|
|
404
488
|
// yet bounded so a hung server still falls back to the per-file path.
|
|
@@ -547,6 +631,13 @@ export class LSPService {
|
|
|
547
631
|
* those falls through to a fresh check rather than serving a stale result.
|
|
548
632
|
*/
|
|
549
633
|
lastKnownContentHash = new Map();
|
|
634
|
+
/**
|
|
635
|
+
* #1783: what content actually landed on a language server, per document.
|
|
636
|
+
* The drift sweep compares disk (size, mtime) against these records and
|
|
637
|
+
* resynchronizes anything an untracked edit moved behind the server's back.
|
|
638
|
+
* See `document-drift.ts` for the key design and the pacing rules.
|
|
639
|
+
*/
|
|
640
|
+
documentDrift = new DocumentDriftTracker();
|
|
550
641
|
/**
|
|
551
642
|
* #1095: lazily verifies a stored {@link DiagnosticBinding} against current
|
|
552
643
|
* disk bytes, memoizing the disk fingerprint per (file, mtime) so repeated
|
|
@@ -577,6 +668,42 @@ export class LSPService {
|
|
|
577
668
|
* successful write clears its entry.
|
|
578
669
|
*/
|
|
579
670
|
notifyWriteBackpressureStreak = new Map();
|
|
671
|
+
/**
|
|
672
|
+
* #1714: unacknowledged auxiliary document notifies per server key
|
|
673
|
+
* ("serverId:normalizedRoot" — the same identity as every other gate here).
|
|
674
|
+
*
|
|
675
|
+
* `unacked` counts notifies ISSUED to this client that the server has not yet
|
|
676
|
+
* been proven to have PROCESSED. It rises with each write the sweep hands over
|
|
677
|
+
* and falls only when a drain barrier round-trips (see {@link paceAuxNotify}).
|
|
678
|
+
* `drain` holds the one in-flight barrier so a burst shares a single
|
|
679
|
+
* round-trip instead of each touch sending its own.
|
|
680
|
+
*
|
|
681
|
+
* `gateOpen` is the fail-open latch. A scanner that will not answer the
|
|
682
|
+
* barrier inside the caller's budget has stopped being a pacing problem and
|
|
683
|
+
* become a stall, which #743's write deadline, streak and wedge timer already
|
|
684
|
+
* own — and they own it by DEMOTING and respawning, which pacing can never do.
|
|
685
|
+
* Once latched, this gate steps aside for the rest of the client's life and
|
|
686
|
+
* every notify takes the pre-#1714 path. It re-arms on the only event that
|
|
687
|
+
* means the stall is over: a new client generation, which gets a new record.
|
|
688
|
+
*
|
|
689
|
+
* Cleared wholesale by the service teardown (`session_start` runs through it),
|
|
690
|
+
* by {@link demoteForNotifyStall}, and per key whenever the client identity
|
|
691
|
+
* changes, so no count can outlive the client it describes.
|
|
692
|
+
*/
|
|
693
|
+
auxNotifyInflight = new Map();
|
|
694
|
+
/**
|
|
695
|
+
* #1459: the ONE outstanding auxiliary notify write per server key
|
|
696
|
+
* ("serverId:normalizedRoot"). A `reopenOnResync` scanner re-parses the whole
|
|
697
|
+
* file on every `didOpen`, so a `clientScope: "all"` sweep that fans out
|
|
698
|
+
* across a neighbour set pushes N full re-scans at it inside a few
|
|
699
|
+
* milliseconds; its stdin stops draining and the #743 write deadline expires
|
|
700
|
+
* for each one, which walked the breaker open in three touches. The gate keeps
|
|
701
|
+
* a sweep to one in-flight resync per auxiliary: while one is outstanding the
|
|
702
|
+
* next touch DEFERS its write and reports the server as uncovered instead of
|
|
703
|
+
* adding to the flood. `startedAt` dates the outstanding write so a write that
|
|
704
|
+
* never lands is still demoted (see {@link demoteForNotifyStall}).
|
|
705
|
+
*/
|
|
706
|
+
outstandingAuxNotifyWrites = new Map();
|
|
580
707
|
/** LRU clock for capacity eviction, keyed by the canonical server/root key. */
|
|
581
708
|
clientLastUsedAt = new Map();
|
|
582
709
|
/**
|
|
@@ -946,6 +1073,22 @@ export class LSPService {
|
|
|
946
1073
|
return;
|
|
947
1074
|
}
|
|
948
1075
|
this.notifyWriteBackpressureStreak.delete(key);
|
|
1076
|
+
this.demoteForNotifyStall(key, entry, filePath, {
|
|
1077
|
+
consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* The demotion itself, shared by the #743 consecutive-timeout streak and the
|
|
1082
|
+
* #1459 wedged-write rule. Both mean the same thing — this client's input path
|
|
1083
|
+
* is not moving — and both need the same teardown.
|
|
1084
|
+
*/
|
|
1085
|
+
demoteForNotifyStall(key, entry, filePath, reason) {
|
|
1086
|
+
this.notifyWriteBackpressureStreak.delete(key);
|
|
1087
|
+
this.outstandingAuxNotifyWrites.delete(key);
|
|
1088
|
+
// #1714: the demoted client is torn down, so its backlog count describes a
|
|
1089
|
+
// process that no longer exists. Leaving it would make the replacement start
|
|
1090
|
+
// at the ceiling and pay a barrier on its first file.
|
|
1091
|
+
this.auxNotifyInflight.delete(key);
|
|
949
1092
|
this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
|
|
950
1093
|
void entry.client.shutdown().catch(() => { });
|
|
951
1094
|
this.state.clients.delete(key);
|
|
@@ -961,10 +1104,432 @@ export class LSPService {
|
|
|
961
1104
|
metadata: {
|
|
962
1105
|
serverId: entry.info.id,
|
|
963
1106
|
cooldownMs: BROKEN_BASE_COOLDOWN_MS,
|
|
964
|
-
|
|
1107
|
+
...reason,
|
|
1108
|
+
},
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* #1459: the caller's write deadline is a LATENCY bound, not a health verdict.
|
|
1113
|
+
* A scanner whose `didOpen` lands a second after we stopped waiting is slow,
|
|
1114
|
+
* not broken, so its late success retracts the timeout it was charged for.
|
|
1115
|
+
* Without this, three slow-but-healthy scans in a row opened the breaker and
|
|
1116
|
+
* blacked out the security lane for 15 s.
|
|
1117
|
+
*/
|
|
1118
|
+
retractNotifyWriteBackpressure(key, serverId, filePath, outstandingMs, client) {
|
|
1119
|
+
// Generation-checked, exactly like the gate: a predecessor's late landing
|
|
1120
|
+
// must not decrement its SUCCESSOR's streak and mask a real stall.
|
|
1121
|
+
if (this.state.clients.get(key) !== client)
|
|
1122
|
+
return;
|
|
1123
|
+
const streak = this.notifyWriteBackpressureStreak.get(key);
|
|
1124
|
+
if (!streak)
|
|
1125
|
+
return;
|
|
1126
|
+
const streakAfter = streak - 1;
|
|
1127
|
+
if (streakAfter <= 0)
|
|
1128
|
+
this.notifyWriteBackpressureStreak.delete(key);
|
|
1129
|
+
else
|
|
1130
|
+
this.notifyWriteBackpressureStreak.set(key, streakAfter);
|
|
1131
|
+
logLatency({
|
|
1132
|
+
type: "phase",
|
|
1133
|
+
phase: "lsp_notify_write_late_landed",
|
|
1134
|
+
filePath: normalizeMapKey(filePath),
|
|
1135
|
+
durationMs: outstandingMs,
|
|
1136
|
+
metadata: { serverId, outstandingMs, streakAfter },
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* #1714: record that one more document notify went to this auxiliary.
|
|
1141
|
+
*
|
|
1142
|
+
* Counted at ISSUE time, not on the write's settle: the backlog the sweep
|
|
1143
|
+
* builds is what the server still has to read, and a write that has not landed
|
|
1144
|
+
* yet is part of it. A client-identity change resets the count, because a
|
|
1145
|
+
* respawned server carries none of its predecessor's backlog.
|
|
1146
|
+
*/
|
|
1147
|
+
noteAuxNotifyIssued(key, client) {
|
|
1148
|
+
const record = this.auxNotifyInflight.get(key);
|
|
1149
|
+
if (record && record.client === client) {
|
|
1150
|
+
record.unacked += 1;
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
this.auxNotifyInflight.set(key, { client, unacked: 1 });
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* #1714: is this auxiliary already holding as many documents as it may?
|
|
1157
|
+
*
|
|
1158
|
+
* Read by the sweep's PRE-OPEN pass, which writes `didOpen` directly instead
|
|
1159
|
+
* of going through `touchFile` and so meets neither the #1459 slot gate nor
|
|
1160
|
+
* the drain barrier. Pre-opening is explicitly best-effort — the file's own
|
|
1161
|
+
* `touchFile` opens it a moment later, through the barrier — so a backlogged
|
|
1162
|
+
* scanner is simply left out of the warm-up burst rather than handed a second
|
|
1163
|
+
* copy of every file in the chunk.
|
|
1164
|
+
*/
|
|
1165
|
+
auxNotifyBacklogAtCeiling(key, entry) {
|
|
1166
|
+
const record = this.auxNotifyInflight.get(key);
|
|
1167
|
+
if (!record || record.client !== entry.client)
|
|
1168
|
+
return false;
|
|
1169
|
+
// Latched open: this scanner is a stall, not a pacing problem, and the
|
|
1170
|
+
// breaker owns it. Step aside here too rather than half-throttling it.
|
|
1171
|
+
if (record.gateOpen)
|
|
1172
|
+
return false;
|
|
1173
|
+
return record.unacked >= auxNotifyInflightLimit(entry.info);
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* #1714: hold the next notify until this auxiliary has proven it PROCESSED the
|
|
1177
|
+
* ones already sent. Returns when the caller may write — it never refuses.
|
|
1178
|
+
*
|
|
1179
|
+
* Under the limit there is nothing to prove, so the common case returns without
|
|
1180
|
+
* awaiting anything and the sweep runs at full speed. At the limit the gate
|
|
1181
|
+
* sends ONE request round-trip (`pingLiveness`, #1277,
|
|
1182
|
+
* clients/lsp/client.ts:2577).
|
|
1183
|
+
*
|
|
1184
|
+
* WHY A REPLY PROVES PROCESSING, measured rather than assumed. ast-grep-lsp is
|
|
1185
|
+
* tower-lsp-server and drains its message stream in order on one task, so a
|
|
1186
|
+
* request written after N `didOpen`s is answered after those N are scanned.
|
|
1187
|
+
* Live probe against the real binary, 30 real repository files:
|
|
1188
|
+
*
|
|
1189
|
+
* idle `workspace/symbol` reply 0 ms
|
|
1190
|
+
* after 30 didOpens 2263 ms, 29 of 30 publishes already in
|
|
1191
|
+
* all 30 processed 2330 ms
|
|
1192
|
+
*
|
|
1193
|
+
* idle `textDocument/hover` reply 1 ms
|
|
1194
|
+
* after 30 didOpens 2086 ms, 29 of 30 publishes already in
|
|
1195
|
+
*
|
|
1196
|
+
* Idle-zero to loaded-seconds, landing within one document of the whole
|
|
1197
|
+
* backlog, is the ordering property this gate needs; one document of slack is
|
|
1198
|
+
* immaterial against a ceiling of 4 to 8.
|
|
1199
|
+
*
|
|
1200
|
+
* A server that answered requests off a SEPARATE task would not give this
|
|
1201
|
+
* proof. What keeps that server safe is NOT the fail-open latch below: the
|
|
1202
|
+
* latch never arms for it. Such a server answers the barrier instantly,
|
|
1203
|
+
* `unacked` resets, and the gate stays inert for the whole sweep. Safety comes
|
|
1204
|
+
* from the outcome that inertness produces — the notify sequence is exactly
|
|
1205
|
+
* the pre-#1714 one, and #743's write deadline, backpressure streak and wedge
|
|
1206
|
+
* timer own the stall the same way they did before this change. The throttle
|
|
1207
|
+
* buys such a server nothing; it also costs it nothing.
|
|
1208
|
+
*
|
|
1209
|
+
* The wait is bounded by the CALLER's remaining budget, never by a schedule of
|
|
1210
|
+
* its own: every waiter gives the shared round-trip only `waitMs`, so a caller
|
|
1211
|
+
* that asked for a 1 s touch still gets one.
|
|
1212
|
+
*
|
|
1213
|
+
* A waiter whose budget runs out does NOT defer the file. It latches the gate
|
|
1214
|
+
* open and falls through to the write. Pacing exists to stop a healthy-but-slow
|
|
1215
|
+
* scanner drowning; a scanner that will not answer at all is a stall, and #743's
|
|
1216
|
+
* write deadline, backpressure streak and wedge timer already own that case —
|
|
1217
|
+
* they demote and respawn it, which is the self-heal that recovered the live
|
|
1218
|
+
* session. Deferring instead withheld the write, accrued no strike, and left
|
|
1219
|
+
* the sweep with no exit.
|
|
1220
|
+
*
|
|
1221
|
+
* Fails OPEN for a client with no liveness round-trip: unmeasurable is not the
|
|
1222
|
+
* same as backlogged, and the codebase already reads this capability as
|
|
1223
|
+
* `pingLiveness?.() ?? true` (clients/lsp/client.ts:281). Every real client
|
|
1224
|
+
* provides it.
|
|
1225
|
+
*/
|
|
1226
|
+
async paceAuxNotify(key, entry, filePath, waitMs, context) {
|
|
1227
|
+
const record = this.auxNotifyInflight.get(key);
|
|
1228
|
+
if (!record)
|
|
1229
|
+
return;
|
|
1230
|
+
if (record.client !== entry.client) {
|
|
1231
|
+
// A previous generation's backlog says nothing about this client.
|
|
1232
|
+
this.auxNotifyInflight.delete(key);
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
// Already handed to the breaker: no barrier, no wait, no extra cost per
|
|
1236
|
+
// file. This is what stops a stalled scanner turning every remaining file
|
|
1237
|
+
// into a fresh full-budget wait.
|
|
1238
|
+
if (record.gateOpen)
|
|
1239
|
+
return;
|
|
1240
|
+
const limit = auxNotifyInflightLimit(entry.info);
|
|
1241
|
+
if (record.unacked < limit)
|
|
1242
|
+
return;
|
|
1243
|
+
const ping = entry.client.pingLiveness;
|
|
1244
|
+
if (!ping) {
|
|
1245
|
+
record.unacked = 0;
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
if (waitMs <= 0) {
|
|
1249
|
+
this.openAuxNotifyGate(key, entry, filePath, context, {
|
|
1250
|
+
unacked: record.unacked,
|
|
1251
|
+
limit,
|
|
1252
|
+
waitMs,
|
|
1253
|
+
durationMs: 0,
|
|
1254
|
+
});
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
if (!record.drain) {
|
|
1258
|
+
const startedAt = Date.now();
|
|
1259
|
+
const outstanding = record.unacked;
|
|
1260
|
+
const client = entry.client;
|
|
1261
|
+
const barrier = (async () => {
|
|
1262
|
+
let drained = false;
|
|
1263
|
+
try {
|
|
1264
|
+
drained = await ping.call(client, waitMs);
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
// A ping that throws proves nothing about the backlog; treat it as
|
|
1268
|
+
// undrained rather than waving the next write through.
|
|
1269
|
+
drained = false;
|
|
1270
|
+
}
|
|
1271
|
+
const current = this.auxNotifyInflight.get(key);
|
|
1272
|
+
if (current === record && current.client === client) {
|
|
1273
|
+
current.drain = undefined;
|
|
1274
|
+
// Subtract the snapshot rather than zeroing: a concurrent touch may
|
|
1275
|
+
// have issued a write after this round-trip was sent, and that write
|
|
1276
|
+
// is still unacknowledged.
|
|
1277
|
+
if (drained)
|
|
1278
|
+
current.unacked = Math.max(0, current.unacked - outstanding);
|
|
1279
|
+
// Deliberately no `else` latch here. A barrier is only ever created
|
|
1280
|
+
// by a call that goes on to await it, so a negative result always
|
|
1281
|
+
// reaches a waiter — as `false`, or as that waiter's own timeout —
|
|
1282
|
+
// and the waiter is what latches. A second latch here would be
|
|
1283
|
+
// unreachable, and no test could hold it honest.
|
|
1284
|
+
}
|
|
1285
|
+
this.noteDrainBarrierOutcome(key, entry, filePath, context, {
|
|
1286
|
+
unacked: outstanding,
|
|
1287
|
+
limit,
|
|
1288
|
+
waitMs,
|
|
1289
|
+
durationMs: Date.now() - startedAt,
|
|
1290
|
+
outcome: drained ? "drained" : "stalled",
|
|
1291
|
+
});
|
|
1292
|
+
return drained;
|
|
1293
|
+
})();
|
|
1294
|
+
barrier.catch(() => { });
|
|
1295
|
+
record.drain = barrier;
|
|
1296
|
+
record.stallLogged = false;
|
|
1297
|
+
}
|
|
1298
|
+
// Each waiter spends only its OWN remaining budget on the shared barrier.
|
|
1299
|
+
const waitStartedAt = Date.now();
|
|
1300
|
+
const drained = await withDeadline(record.drain, {
|
|
1301
|
+
ms: waitMs,
|
|
1302
|
+
onTimeout: "undefined",
|
|
1303
|
+
onReject: "undefined",
|
|
1304
|
+
});
|
|
1305
|
+
if (drained !== true) {
|
|
1306
|
+
// The waiter gave up before the round-trip answered. Latch open and let
|
|
1307
|
+
// the caller write: a barrier whose ping never answers would otherwise
|
|
1308
|
+
// tax every remaining file a full budget for nothing.
|
|
1309
|
+
this.openAuxNotifyGate(key, entry, filePath, context, {
|
|
1310
|
+
unacked: record.unacked,
|
|
1311
|
+
limit,
|
|
1312
|
+
waitMs,
|
|
1313
|
+
durationMs: Date.now() - waitStartedAt,
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* #1714: stop pacing this client and hand it to the breaker.
|
|
1319
|
+
*
|
|
1320
|
+
* Latched, not cooled down — a timer here would be a schedule of this gate's
|
|
1321
|
+
* own, and it would race the caller's cadence. The latch clears only when the
|
|
1322
|
+
* record does: a demotion ({@link demoteForNotifyStall}), a client-identity
|
|
1323
|
+
* change, or the service teardown. Every one of those means a new server
|
|
1324
|
+
* process, which is the only event that makes the old backlog meaningless.
|
|
1325
|
+
*/
|
|
1326
|
+
openAuxNotifyGate(key, entry, filePath, context, detail) {
|
|
1327
|
+
const record = this.auxNotifyInflight.get(key);
|
|
1328
|
+
if (record && record.client === entry.client) {
|
|
1329
|
+
// `drain` is deliberately left alone. Once the gate is open nothing
|
|
1330
|
+
// reads it again — `paceAuxNotify` returns above the barrier — and the
|
|
1331
|
+
// round-trip's own settle handler clears it when it finally answers.
|
|
1332
|
+
// Clearing it here would be a write no test could hold honest.
|
|
1333
|
+
record.gateOpen = true;
|
|
1334
|
+
}
|
|
1335
|
+
this.noteDrainBarrierOutcome(key, entry, filePath, context, {
|
|
1336
|
+
...detail,
|
|
1337
|
+
outcome: "stalled",
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* #1714: emit at most ONE record per barrier. A stalled barrier can be
|
|
1342
|
+
* abandoned by every file left in the sweep, and one row per file would turn a
|
|
1343
|
+
* single stuck scanner into hundreds of identical records.
|
|
1344
|
+
*/
|
|
1345
|
+
noteDrainBarrierOutcome(key, entry, filePath, context, detail) {
|
|
1346
|
+
const record = this.auxNotifyInflight.get(key);
|
|
1347
|
+
if (detail.outcome === "stalled" && record) {
|
|
1348
|
+
if (record.stallLogged)
|
|
1349
|
+
return;
|
|
1350
|
+
record.stallLogged = true;
|
|
1351
|
+
}
|
|
1352
|
+
this.logDrainBarrier(key, entry, filePath, context, detail);
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* #1714: one row per barrier, not per waiter — a burst of N notifies produces
|
|
1356
|
+
* at most one round-trip and one record, so the volume is bounded by the sweep
|
|
1357
|
+
* divided by the limit. Names the server and the file that hit the ceiling, so
|
|
1358
|
+
* "which scanner is falling behind" survives aggregation.
|
|
1359
|
+
*/
|
|
1360
|
+
logDrainBarrier(key, entry, filePath, context, detail) {
|
|
1361
|
+
if (detail.outcome === "stalled") {
|
|
1362
|
+
incrementDegradationCount({
|
|
1363
|
+
kind: "lsp-notify-inflight-stall",
|
|
1364
|
+
subject: `${entry.info.id}:${normalizeMapKey(filePath)}`,
|
|
1365
|
+
reason: `notify barrier stalled with ${detail.unacked} unacknowledged writes`,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
logLatency({
|
|
1369
|
+
type: "phase",
|
|
1370
|
+
phase: "lsp_notify_inflight_barrier",
|
|
1371
|
+
filePath: normalizeMapKey(filePath),
|
|
1372
|
+
durationMs: detail.durationMs,
|
|
1373
|
+
metadata: {
|
|
1374
|
+
serverId: entry.info.id,
|
|
1375
|
+
clientKey: key,
|
|
1376
|
+
source: context.source,
|
|
1377
|
+
clientScope: context.clientScope,
|
|
1378
|
+
...detail,
|
|
965
1379
|
},
|
|
966
1380
|
});
|
|
967
1381
|
}
|
|
1382
|
+
/**
|
|
1383
|
+
* #1459: take this auxiliary's resync slot, waiting up to `budgetMs` for it.
|
|
1384
|
+
*
|
|
1385
|
+
* The gate is a QUEUE, not a drop: a healthy scanner accepts a `didOpen` in
|
|
1386
|
+
* milliseconds, so a sweep's neighbours take their turns one after another and
|
|
1387
|
+
* every file still gets scanned — what the gate prevents is N simultaneous
|
|
1388
|
+
* full re-scans flooding one stdin. Only a scanner that cannot accept a write
|
|
1389
|
+
* inside the budget makes a waiter give up, and giving up is reported as a
|
|
1390
|
+
* coverage gap rather than pushed anyway.
|
|
1391
|
+
*
|
|
1392
|
+
* The slot is CLAIMED SYNCHRONOUSLY: the check and the insert sit in one
|
|
1393
|
+
* uninterrupted run of statements, and the returned handle owns the entry. A
|
|
1394
|
+
* version that returned "the slot looks free, go write" and let the caller
|
|
1395
|
+
* insert its own record after an `await` was not a gate at all — when the
|
|
1396
|
+
* holder's write landed, every waiter woke in the same microtask batch, each
|
|
1397
|
+
* read an empty map, and all of them wrote at once (measured: one write at t=0,
|
|
1398
|
+
* then a five-wide flood at t=50 for six touches). That is #1459's own root
|
|
1399
|
+
* cause rebuilt inside the fix for it.
|
|
1400
|
+
*
|
|
1401
|
+
* Returns a handle with `release()` (call on the write's settle, idempotent), or
|
|
1402
|
+
* a verdict naming how long the blocking write has been outstanding.
|
|
1403
|
+
*/
|
|
1404
|
+
async claimAuxNotifySlot(clientKey, entry, filePath, budgetMs) {
|
|
1405
|
+
const deadline = Date.now() + budgetMs;
|
|
1406
|
+
for (;;) {
|
|
1407
|
+
const outstanding = this.outstandingAuxNotifyWrites.get(clientKey);
|
|
1408
|
+
// A record left behind by a PREVIOUS client generation (evicted,
|
|
1409
|
+
// respawned) says nothing about this client's stdin — drop it, so a stale
|
|
1410
|
+
// entry can never starve a healthy server.
|
|
1411
|
+
if (outstanding && outstanding.client !== entry.client) {
|
|
1412
|
+
this.outstandingAuxNotifyWrites.delete(clientKey);
|
|
1413
|
+
}
|
|
1414
|
+
else if (outstanding) {
|
|
1415
|
+
const outstandingMs = Date.now() - outstanding.startedAt;
|
|
1416
|
+
const remainingMs = deadline - Date.now();
|
|
1417
|
+
if (remainingMs <= 0)
|
|
1418
|
+
return { outstandingMs };
|
|
1419
|
+
// `settled` never rejects, so this only resolves or times out.
|
|
1420
|
+
await withDeadline(outstanding.settled, {
|
|
1421
|
+
ms: remainingMs,
|
|
1422
|
+
onTimeout: "undefined",
|
|
1423
|
+
onReject: "undefined",
|
|
1424
|
+
});
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
// This client was evicted or replaced while we queued — writing to it
|
|
1428
|
+
// would target a retired generation. Report the gap instead.
|
|
1429
|
+
//
|
|
1430
|
+
// `!== entry.client` covers BOTH shapes, and the missing-entry one is the
|
|
1431
|
+
// dangerous half: eviction (idle, capacity, a #743 demotion) DELETES the
|
|
1432
|
+
// registry entry, so an `undefined`-exempting guard would wave the waiter
|
|
1433
|
+
// through to a corpse whose write resolves `true` — and `markTouched` would
|
|
1434
|
+
// then record this content as delivered, which is exactly the #1253
|
|
1435
|
+
// laundering the debounce entry must never do.
|
|
1436
|
+
const current = this.state.clients.get(clientKey);
|
|
1437
|
+
if (current !== entry.client) {
|
|
1438
|
+
return { outstandingMs: 0 };
|
|
1439
|
+
}
|
|
1440
|
+
// ---- No `await` from here to the `set` below: the claim is atomic. ----
|
|
1441
|
+
let resolveSettled;
|
|
1442
|
+
const settled = new Promise((resolve) => {
|
|
1443
|
+
resolveSettled = resolve;
|
|
1444
|
+
});
|
|
1445
|
+
const token = {
|
|
1446
|
+
startedAt: Date.now(),
|
|
1447
|
+
client: entry.client,
|
|
1448
|
+
settled,
|
|
1449
|
+
// A write nothing accepts for the whole wedge window is a dead input
|
|
1450
|
+
// path, not a slow scan. Armed HERE rather than checked by the next
|
|
1451
|
+
// waiter: inside a burst every waiter arrives within one budget, so a
|
|
1452
|
+
// waiter-side check could never see the wedge window elapse and a
|
|
1453
|
+
// wedged scanner was never demoted. Unref'd so it cannot hold a
|
|
1454
|
+
// one-shot host alive, and cleared on release.
|
|
1455
|
+
wedgeTimer: setTimeout(() => {
|
|
1456
|
+
if (this.outstandingAuxNotifyWrites.get(clientKey) !== token)
|
|
1457
|
+
return;
|
|
1458
|
+
this.demoteForNotifyStall(clientKey, entry, filePath, {
|
|
1459
|
+
outstandingMs: Date.now() - token.startedAt,
|
|
1460
|
+
});
|
|
1461
|
+
resolveSettled?.();
|
|
1462
|
+
}, notifyWedgedMs()),
|
|
1463
|
+
};
|
|
1464
|
+
token.wedgeTimer.unref?.();
|
|
1465
|
+
this.outstandingAuxNotifyWrites.set(clientKey, token);
|
|
1466
|
+
return {
|
|
1467
|
+
release: () => {
|
|
1468
|
+
clearTimeout(token.wedgeTimer);
|
|
1469
|
+
if (this.outstandingAuxNotifyWrites.get(clientKey) === token) {
|
|
1470
|
+
this.outstandingAuxNotifyWrites.delete(clientKey);
|
|
1471
|
+
}
|
|
1472
|
+
resolveSettled?.();
|
|
1473
|
+
},
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* #1459: the auxiliary scanners that WOULD have attached to this touch but got
|
|
1479
|
+
* no client because their circuit breaker is open (cooldown or permanent).
|
|
1480
|
+
*
|
|
1481
|
+
* A skipped scanner said nothing about the file. Until now it also left no
|
|
1482
|
+
* trace on the result: it simply dropped out of `spawned`, and the touch
|
|
1483
|
+
* resolved `confirmation: "confirmed"` on the strength of whoever was left —
|
|
1484
|
+
* so a 15 s opengrep cooldown read as "scanned, clean" for every file swept
|
|
1485
|
+
* inside it. Naming the scanners here narrows the touch to `"partial"` instead,
|
|
1486
|
+
* which every coverage consumer already fails closed on (#1470).
|
|
1487
|
+
*
|
|
1488
|
+
* Deliberately AUXILIARY-only. A broken primary is already visible through the
|
|
1489
|
+
* `no_clients` failure kind and the demonstrated-cold path; the false-clean
|
|
1490
|
+
* hazard this addresses is the scanner lane, where an empty result is the
|
|
1491
|
+
* normal, expected answer.
|
|
1492
|
+
*
|
|
1493
|
+
* SCOPE, stated so the next reader does not assume the room is closed: this
|
|
1494
|
+
* covers the BREAKER doors only (cooldown + permanent). `ensureClientForServer`
|
|
1495
|
+
* also drops a scanner for a temporarily-unavailable command (the #1496 latch),
|
|
1496
|
+
* `optionalDisabled`, a spawn failure, or capacity eviction. Those are the same
|
|
1497
|
+
* defect class in the same lane, but they are persistent-absence states rather
|
|
1498
|
+
* than a transient blackout, so flagging every touch partial for them is a
|
|
1499
|
+
* broader behavior change than #1459 is scoped to make. Tracked separately.
|
|
1500
|
+
*/
|
|
1501
|
+
async brokenSkippedAuxiliaryServerIds(filePath, clientScope, options, spawned) {
|
|
1502
|
+
// "primary" scope attaches no auxiliaries at all, so nothing was skipped.
|
|
1503
|
+
if (clientScope === "primary")
|
|
1504
|
+
return [];
|
|
1505
|
+
const enabledAuxiliaries = clientScope === "with-auxiliary"
|
|
1506
|
+
? new Set(options.auxiliaryServerIds ?? [])
|
|
1507
|
+
: undefined;
|
|
1508
|
+
const attached = new Set(spawned.map((entry) => entry.info.id));
|
|
1509
|
+
const skipped = [];
|
|
1510
|
+
for (const server of getServersForFileWithConfig(filePath)) {
|
|
1511
|
+
if (server.role !== "auxiliary")
|
|
1512
|
+
continue;
|
|
1513
|
+
if (attached.has(server.id))
|
|
1514
|
+
continue;
|
|
1515
|
+
// An explicitly excluded server (the #584 workspace-sweep exclusion) was
|
|
1516
|
+
// never asked, and its findings come from its own CLI extractor — that is
|
|
1517
|
+
// a routing decision, not a coverage gap.
|
|
1518
|
+
if (options.excludeServerIds?.has(server.id))
|
|
1519
|
+
continue;
|
|
1520
|
+
if (enabledAuxiliaries && !enabledAuxiliaries.has(server.id))
|
|
1521
|
+
continue;
|
|
1522
|
+
const key = await this.demonstratedReadyKeyFor(server, filePath);
|
|
1523
|
+
if (!key)
|
|
1524
|
+
continue;
|
|
1525
|
+
const brokenUntil = this.state.broken.get(key);
|
|
1526
|
+
if (this.permanentlyBroken.has(key) ||
|
|
1527
|
+
(typeof brokenUntil === "number" && brokenUntil > Date.now())) {
|
|
1528
|
+
skipped.push(server.id);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
return skipped;
|
|
1532
|
+
}
|
|
968
1533
|
activeClientsForCwd(cwd, priorityServerIds = []) {
|
|
969
1534
|
const normalizedCwd = normalizeMapKey(cwd);
|
|
970
1535
|
const priority = new Map(priorityServerIds.map((serverId, index) => [serverId, index]));
|
|
@@ -989,7 +1554,7 @@ export class LSPService {
|
|
|
989
1554
|
* Get or create LSP client for a file
|
|
990
1555
|
* Prevents duplicate client creation via in-flight promise tracking
|
|
991
1556
|
*/
|
|
992
|
-
async getClientForFile(filePath, maxWaitMs, hardCapMs) {
|
|
1557
|
+
async getClientForFile(filePath, maxWaitMs, hardCapMs, resolvedRoots, waitSkipReasons) {
|
|
993
1558
|
if (this.checkDestroyed())
|
|
994
1559
|
return undefined;
|
|
995
1560
|
// Primary selection considers language servers only — auxiliary servers
|
|
@@ -1008,12 +1573,25 @@ export class LSPService {
|
|
|
1008
1573
|
? Math.min(serverBaseMs, hardCapMs)
|
|
1009
1574
|
: hardCapMs
|
|
1010
1575
|
: serverBaseMs;
|
|
1576
|
+
let knownSlowResolve;
|
|
1577
|
+
const knownSlowSentinel = Symbol("lsp-client-wait-known-slow");
|
|
1578
|
+
const knownSlow = new Promise((resolve) => {
|
|
1579
|
+
knownSlowResolve = () => resolve(knownSlowSentinel);
|
|
1580
|
+
});
|
|
1581
|
+
const noteSpawnInFlight = (serverId) => {
|
|
1582
|
+
const knownDurationMs = getSuccessfulLspSpawnDurationMs(serverId);
|
|
1583
|
+
if (knownDurationMs !== undefined && knownDurationMs > effectiveMaxWaitMs * 2) {
|
|
1584
|
+
// Let a completion microtask already queued by the acquisition win
|
|
1585
|
+
// before the shortcut decision is observed by Promise.race.
|
|
1586
|
+
queueMicrotask(() => knownSlowResolve?.());
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1011
1589
|
const withBudget = async () => {
|
|
1012
1590
|
if (servers.length === 0)
|
|
1013
1591
|
return undefined;
|
|
1014
1592
|
// Try each matching server
|
|
1015
1593
|
for (const server of servers) {
|
|
1016
|
-
const spawned = await this.ensureClientForServer(filePath, server);
|
|
1594
|
+
const spawned = await this.ensureClientForServer(filePath, server, resolvedRoots, noteSpawnInFlight);
|
|
1017
1595
|
if (spawned) {
|
|
1018
1596
|
logLatency({
|
|
1019
1597
|
type: "phase",
|
|
@@ -1071,6 +1649,7 @@ export class LSPService {
|
|
|
1071
1649
|
try {
|
|
1072
1650
|
waitResult = await Promise.race([
|
|
1073
1651
|
withBudget(),
|
|
1652
|
+
knownSlow,
|
|
1074
1653
|
new Promise((resolve) => {
|
|
1075
1654
|
waitTimer = setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs);
|
|
1076
1655
|
}),
|
|
@@ -1080,6 +1659,32 @@ export class LSPService {
|
|
|
1080
1659
|
if (waitTimer)
|
|
1081
1660
|
clearTimeout(waitTimer);
|
|
1082
1661
|
}
|
|
1662
|
+
if (waitResult === knownSlowSentinel) {
|
|
1663
|
+
// `inFlight` is cleared in ensureClientForServer's finally block, so a
|
|
1664
|
+
// settled acquisition can still be present here. Re-read the published
|
|
1665
|
+
// clients at the decision point; a usable client outranks the sentinel.
|
|
1666
|
+
for (const server of servers) {
|
|
1667
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
1668
|
+
const client = root
|
|
1669
|
+
? this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`)
|
|
1670
|
+
: undefined;
|
|
1671
|
+
if (client?.isAlive())
|
|
1672
|
+
return { client, info: server };
|
|
1673
|
+
}
|
|
1674
|
+
waitSkipReasons?.add("budget_skipped_known_slow");
|
|
1675
|
+
logLatency({
|
|
1676
|
+
type: "phase",
|
|
1677
|
+
phase: "lsp_client_wait_skipped",
|
|
1678
|
+
filePath,
|
|
1679
|
+
durationMs: 0,
|
|
1680
|
+
metadata: {
|
|
1681
|
+
maxWaitMs: effectiveMaxWaitMs,
|
|
1682
|
+
serverIds: servers.map((server) => server.id),
|
|
1683
|
+
reason: "budget_skipped_known_slow",
|
|
1684
|
+
},
|
|
1685
|
+
});
|
|
1686
|
+
return undefined;
|
|
1687
|
+
}
|
|
1083
1688
|
if (waitResult === timeoutSentinel) {
|
|
1084
1689
|
// Snapshot known client health — scan by serverId prefix (no root needed)
|
|
1085
1690
|
const knownHealth = [...this.state.clients.entries()]
|
|
@@ -1109,7 +1714,7 @@ export class LSPService {
|
|
|
1109
1714
|
* Get or create ALL LSP clients that can serve a file.
|
|
1110
1715
|
* Used for diagnostics aggregation across complementary servers.
|
|
1111
1716
|
*/
|
|
1112
|
-
async getClientsForFile(filePath, excludeServerIds) {
|
|
1717
|
+
async getClientsForFile(filePath, excludeServerIds, resolvedRoots) {
|
|
1113
1718
|
const allServers = getServersForFileWithConfig(filePath);
|
|
1114
1719
|
const servers = excludeServerIds && excludeServerIds.size > 0
|
|
1115
1720
|
? allServers.filter((s) => !excludeServerIds.has(s.id))
|
|
@@ -1120,7 +1725,7 @@ export class LSPService {
|
|
|
1120
1725
|
// that fail the root check are not real spawn attempts.
|
|
1121
1726
|
const roots = await Promise.all(servers.map((s) => this.resolveServerRoot(s, filePath)));
|
|
1122
1727
|
const serverCountAttempted = roots.filter(Boolean).length;
|
|
1123
|
-
const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
|
|
1728
|
+
const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server, resolvedRoots)));
|
|
1124
1729
|
return {
|
|
1125
1730
|
clients: spawned.filter((entry) => Boolean(entry)),
|
|
1126
1731
|
serverCountAttempted,
|
|
@@ -1180,7 +1785,194 @@ export class LSPService {
|
|
|
1180
1785
|
}
|
|
1181
1786
|
return false;
|
|
1182
1787
|
}
|
|
1183
|
-
|
|
1788
|
+
/**
|
|
1789
|
+
* #1668: deliver a `workspace/didChangeWatchedFiles` event for a disk
|
|
1790
|
+
* change the client did not author through open-document sync — a bash
|
|
1791
|
+
* write/delete, or any other external change. `type` is the LSP
|
|
1792
|
+
* `FileChangeType` (1 Created, 2 Changed, 3 Deleted).
|
|
1793
|
+
*
|
|
1794
|
+
* Only reaches ALREADY-ACTIVE clients for this file's servers — a server
|
|
1795
|
+
* that hasn't been spawned yet has no stale cache to correct, so this
|
|
1796
|
+
* never spawns one just to deliver the notification. Each affected
|
|
1797
|
+
* client enqueues into its own #271 debounced queue, so a burst of
|
|
1798
|
+
* external changes still coalesces into one notification per server.
|
|
1799
|
+
*/
|
|
1800
|
+
async notifyExternalFileChange(filePath, type) {
|
|
1801
|
+
if (this.checkDestroyed())
|
|
1802
|
+
return;
|
|
1803
|
+
for (const server of getServersForFileWithConfig(filePath)) {
|
|
1804
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
1805
|
+
if (!root)
|
|
1806
|
+
continue;
|
|
1807
|
+
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
1808
|
+
const existing = this.state.clients.get(key);
|
|
1809
|
+
if (existing?.isAlive()) {
|
|
1810
|
+
existing.notify.watchedFileChange(filePath, type);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* #1783: disk-drift backstop. Stat one batch of the documents a language
|
|
1816
|
+
* server currently holds, and re-push any whose bytes on disk no longer
|
|
1817
|
+
* match what the server was last given.
|
|
1818
|
+
*
|
|
1819
|
+
* Why this exists: an edit made outside the tracked write/edit path (a
|
|
1820
|
+
* bash-tool bulk edit) sends no `didChange`, so the server keeps publishing
|
|
1821
|
+
* pre-edit diagnostics with nothing to correct it. The resync-on-read path
|
|
1822
|
+
* only fires for a file the session reads again; a file that is edited and
|
|
1823
|
+
* never re-read stayed stale for the life of the server.
|
|
1824
|
+
*
|
|
1825
|
+
* Hot-path contract: callers fire this WITHOUT awaiting it. It is
|
|
1826
|
+
* rate-limited to one pass per drift-check interval (10s default),
|
|
1827
|
+
* stats at most 64 documents per pass, reads only the ones whose stat
|
|
1828
|
+
* already diverged, and issues at most 4 resyncs per pass serially. So the
|
|
1829
|
+
* steady-state cost on the touch path is zero, and the worst-case cost of a
|
|
1830
|
+
* pass is 64 stats plus up to 4 reads, off the caller's critical path.
|
|
1831
|
+
*
|
|
1832
|
+
* Never spawns: a document with no live client already holding it open has
|
|
1833
|
+
* no stale view to correct, so its record is dropped instead of resynced.
|
|
1834
|
+
*/
|
|
1835
|
+
async sweepDocumentDrift(options = {}) {
|
|
1836
|
+
if (this.checkDestroyed())
|
|
1837
|
+
return undefined;
|
|
1838
|
+
return this.documentDrift.sweep({
|
|
1839
|
+
resync: async (filePath, content) => {
|
|
1840
|
+
// Reuse the normal touch path so the resync inherits the existing
|
|
1841
|
+
// per-server notify-write budget, the #743 backpressure demotion and
|
|
1842
|
+
// the client-lease machinery. diagnostics:"none" keeps it a pure
|
|
1843
|
+
// content push — the next genuine query gets correct diagnostics
|
|
1844
|
+
// because the server's view is now right, not because this call
|
|
1845
|
+
// waited for them.
|
|
1846
|
+
//
|
|
1847
|
+
// Scope "all" MINUS the servers that are not holding this document:
|
|
1848
|
+
// the resync must cover every view that is actually stale, primary
|
|
1849
|
+
// and auxiliary alike, and the exclusion set is what keeps that from
|
|
1850
|
+
// spawning anything (see serverIdsNotHoldingDocument). The write is
|
|
1851
|
+
// still one per held server through the same bounded notify path, so
|
|
1852
|
+
// the per-pass resync cap continues to bound the total writes.
|
|
1853
|
+
await this.touchFile(filePath, content, {
|
|
1854
|
+
diagnostics: "none",
|
|
1855
|
+
source: "drift_resync",
|
|
1856
|
+
clientScope: "all",
|
|
1857
|
+
excludeServerIds: await this.serverIdsNotHoldingDocument(filePath),
|
|
1858
|
+
});
|
|
1859
|
+
// touchFile swallows a rejected or timed-out notify write so the
|
|
1860
|
+
// caller's edit keeps moving, so its return proves nothing about
|
|
1861
|
+
// whether the heal landed. The drift record is stamped in exactly
|
|
1862
|
+
// one place — recordFullyCoveredSync, and only on full coverage —
|
|
1863
|
+
// so "did the record advance to this content" IS the answer.
|
|
1864
|
+
return (this.documentDrift.peek(filePath)?.fingerprint ===
|
|
1865
|
+
fingerprintDocumentContent(content));
|
|
1866
|
+
},
|
|
1867
|
+
holdsDocument: (filePath) => this.hasLiveClientHoldingDocument(filePath),
|
|
1868
|
+
onDrift: (event) => {
|
|
1869
|
+
// A clean re-stamp, a deleted file and a closed document are all
|
|
1870
|
+
// normal bookkeeping, not degradations. Only a real resync — or a
|
|
1871
|
+
// heal the pacing had to defer — earns a record.
|
|
1872
|
+
if (event.disposition === "unchanged" ||
|
|
1873
|
+
event.disposition === "vanished" ||
|
|
1874
|
+
event.disposition === "unheld") {
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
// Bounded record, per-file so the identity of the stuck document
|
|
1878
|
+
// survives aggregation. `incrementDegradationCount` keeps one entry
|
|
1879
|
+
// per file with an exact repeat tally instead of N entries.
|
|
1880
|
+
incrementDegradationCount({
|
|
1881
|
+
kind: "lsp-document-drift",
|
|
1882
|
+
subject: event.filePath,
|
|
1883
|
+
reason: event.disposition === "deferred"
|
|
1884
|
+
? `disk drift deferred by resync pacing after ${event.driftAgeMs}ms`
|
|
1885
|
+
: event.disposition === "failed"
|
|
1886
|
+
? `resync FAILED after ${event.driftAgeMs}ms of untracked disk drift; view still stale (${event.syncedSize}->${event.diskSize} bytes)`
|
|
1887
|
+
: `resynced after ${event.driftAgeMs}ms of untracked disk drift (${event.syncedSize}->${event.diskSize} bytes)`,
|
|
1888
|
+
});
|
|
1889
|
+
logLatency({
|
|
1890
|
+
type: "phase",
|
|
1891
|
+
phase: "lsp_document_drift",
|
|
1892
|
+
filePath: event.filePath,
|
|
1893
|
+
durationMs: event.driftAgeMs,
|
|
1894
|
+
metadata: {
|
|
1895
|
+
disposition: event.disposition,
|
|
1896
|
+
driftAgeMs: event.driftAgeMs,
|
|
1897
|
+
syncedSize: event.syncedSize,
|
|
1898
|
+
diskSize: event.diskSize,
|
|
1899
|
+
},
|
|
1900
|
+
});
|
|
1901
|
+
},
|
|
1902
|
+
}, options);
|
|
1903
|
+
}
|
|
1904
|
+
/** #1783: tracked drift-record count. Test seam for the reset conformance. */
|
|
1905
|
+
_driftTrackedCountForTests() {
|
|
1906
|
+
return this.documentDrift.size;
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* #1783: does any LIVE client already hold this document open? A record for
|
|
1910
|
+
* a document no server holds is dropped rather than resynced, so the drift
|
|
1911
|
+
* backstop can never spawn a server just to correct a view nobody has.
|
|
1912
|
+
*/
|
|
1913
|
+
hasLiveClientHoldingDocument(filePath) {
|
|
1914
|
+
for (const client of this.state.clients.values()) {
|
|
1915
|
+
if (!client.isAlive())
|
|
1916
|
+
continue;
|
|
1917
|
+
if (documentIsOpenOn(client, filePath))
|
|
1918
|
+
return true;
|
|
1919
|
+
}
|
|
1920
|
+
return false;
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* #1783: server ids for this file whose client is NOT currently holding the
|
|
1924
|
+
* document open — the exclusion set the drift resync passes to
|
|
1925
|
+
* `clientScope:"all"`.
|
|
1926
|
+
*
|
|
1927
|
+
* This is what keeps "resync every server that holds it" and "never spawn"
|
|
1928
|
+
* compatible. `getClientsForFile` filters by this set BEFORE
|
|
1929
|
+
* `ensureClientForServer`, so an excluded server is never reached, and every
|
|
1930
|
+
* remaining server already has a live client that returns from the cache.
|
|
1931
|
+
* Without it, `clientScope:"all"` would spawn the file's other servers, and
|
|
1932
|
+
* `clientScope:"primary"` would leave an auxiliary's view stale while the
|
|
1933
|
+
* record claimed the file was back in sync.
|
|
1934
|
+
*/
|
|
1935
|
+
async serverIdsNotHoldingDocument(filePath) {
|
|
1936
|
+
const exclude = new Set();
|
|
1937
|
+
for (const server of getServersForFileWithConfig(filePath)) {
|
|
1938
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
1939
|
+
const existing = root
|
|
1940
|
+
? this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`)
|
|
1941
|
+
: undefined;
|
|
1942
|
+
if (existing?.isAlive() && documentIsOpenOn(existing, filePath))
|
|
1943
|
+
continue;
|
|
1944
|
+
exclude.add(server.id);
|
|
1945
|
+
}
|
|
1946
|
+
return exclude;
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* #1783: stamp the drift record only when this touch reached EVERY server
|
|
1950
|
+
* that currently holds the document, and every one of those writes landed.
|
|
1951
|
+
*
|
|
1952
|
+
* `at` is the touch's START time, not the moment the write landed. The write
|
|
1953
|
+
* can take up to the notify budget, and an untracked edit inside that window
|
|
1954
|
+
* would otherwise be stamped as already-synchronized and become invisible
|
|
1955
|
+
* (the mtime half of the key compares against this timestamp). Starting the
|
|
1956
|
+
* clock at the touch — within a millisecond or two of the caller's read —
|
|
1957
|
+
* narrows that blind window to the caller's own read-to-touch gap.
|
|
1958
|
+
*/
|
|
1959
|
+
recordFullyCoveredSync(filePath, content, targeted, allWritesLanded, at) {
|
|
1960
|
+
if (!allWritesLanded || targeted.length === 0)
|
|
1961
|
+
return;
|
|
1962
|
+
const targetedClients = new Set(targeted.map((entry) => entry.client));
|
|
1963
|
+
for (const client of this.state.clients.values()) {
|
|
1964
|
+
if (targetedClients.has(client))
|
|
1965
|
+
continue;
|
|
1966
|
+
if (!client.isAlive())
|
|
1967
|
+
continue;
|
|
1968
|
+
// A live client outside this touch's scope still holds the document, so
|
|
1969
|
+
// its view is NOT covered by this content. Recording here would claim it.
|
|
1970
|
+
if (documentIsOpenOn(client, filePath))
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
this.documentDrift.recordSynced(filePath, content, at);
|
|
1974
|
+
}
|
|
1975
|
+
async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight) {
|
|
1184
1976
|
const handoff = this.generationHandoff;
|
|
1185
1977
|
if (handoff) {
|
|
1186
1978
|
await handoff;
|
|
@@ -1193,21 +1985,30 @@ export class LSPService {
|
|
|
1193
1985
|
const root = await this.resolveServerRoot(server, filePath);
|
|
1194
1986
|
if (!root || this.checkDestroyed())
|
|
1195
1987
|
return undefined;
|
|
1988
|
+
if (server.role !== "auxiliary") {
|
|
1989
|
+
resolvedRoots?.set(server.id, normalizeMapKey(root));
|
|
1990
|
+
}
|
|
1196
1991
|
const allowInstall = this.shouldAllowInstall(server.id);
|
|
1197
1992
|
const normalizedRoot = normalizeMapKey(root);
|
|
1198
1993
|
const key = `${server.id}:${normalizedRoot}`;
|
|
1199
1994
|
const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
|
|
1200
1995
|
if (server.availabilityKey &&
|
|
1201
1996
|
isDirectLspCommandTemporarilyUnavailable(server.availabilityKey)) {
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1997
|
+
// #1743: during an outage this path runs once per file per touch,
|
|
1998
|
+
// so a raw write here is a per-file log storm. The ledger counts
|
|
1999
|
+
// every skip exactly, keyed on (command, file); only the first per
|
|
2000
|
+
// pair also writes the detailed record.
|
|
2001
|
+
emitBounded("lsp_client_skipped_unavailable_command", `${server.availabilityKey}:${normalizeMapKey(filePath)}`, {
|
|
1205
2002
|
filePath,
|
|
1206
2003
|
durationMs: 0,
|
|
1207
2004
|
metadata: {
|
|
1208
2005
|
serverId: server.id,
|
|
1209
2006
|
command: server.availabilityKey,
|
|
1210
2007
|
},
|
|
2008
|
+
}, {
|
|
2009
|
+
ledgerKind: "lsp-client-skipped-unavailable-command",
|
|
2010
|
+
risingEdgePer: "identity",
|
|
2011
|
+
reason: `command ${server.availabilityKey} temporarily unavailable`,
|
|
1211
2012
|
});
|
|
1212
2013
|
return undefined;
|
|
1213
2014
|
}
|
|
@@ -1215,15 +2016,20 @@ export class LSPService {
|
|
|
1215
2016
|
return undefined;
|
|
1216
2017
|
}
|
|
1217
2018
|
if (this.permanentlyBroken.has(key)) {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
2019
|
+
// #1743: same per-file-per-touch storm as the unavailable-command
|
|
2020
|
+
// skip above. Identity is (server, file) so a single wedged server
|
|
2021
|
+
// cannot hide which files it is refusing.
|
|
2022
|
+
emitBounded("lsp_client_skipped_broken", `${server.id}:${normalizeMapKey(filePath)}`, {
|
|
1221
2023
|
filePath,
|
|
1222
2024
|
durationMs: 0,
|
|
1223
2025
|
metadata: {
|
|
1224
2026
|
serverId: server.id,
|
|
1225
2027
|
permanent: true,
|
|
1226
2028
|
},
|
|
2029
|
+
}, {
|
|
2030
|
+
ledgerKind: "lsp-client-skipped-broken",
|
|
2031
|
+
risingEdgePer: "identity",
|
|
2032
|
+
reason: `${server.id} latched permanently broken`,
|
|
1227
2033
|
});
|
|
1228
2034
|
return undefined;
|
|
1229
2035
|
}
|
|
@@ -1407,15 +2213,20 @@ export class LSPService {
|
|
|
1407
2213
|
}
|
|
1408
2214
|
const brokenUntil = this.state.broken.get(key);
|
|
1409
2215
|
if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
2216
|
+
// #1743: the breaker-cooldown sibling of the permanently-broken skip
|
|
2217
|
+
// above, sharing its identity so an outage produces one record per
|
|
2218
|
+
// (server, file) rather than one per touch.
|
|
2219
|
+
emitBounded("lsp_client_skipped_broken", `${server.id}:${normalizeMapKey(filePath)}`, {
|
|
1413
2220
|
filePath,
|
|
1414
2221
|
durationMs: 0,
|
|
1415
2222
|
metadata: {
|
|
1416
2223
|
serverId: server.id,
|
|
1417
2224
|
retryInMs: Math.max(0, brokenUntil - Date.now()),
|
|
1418
2225
|
},
|
|
2226
|
+
}, {
|
|
2227
|
+
ledgerKind: "lsp-client-skipped-broken",
|
|
2228
|
+
risingEdgePer: "identity",
|
|
2229
|
+
reason: `${server.id} in breaker cooldown`,
|
|
1419
2230
|
});
|
|
1420
2231
|
return undefined;
|
|
1421
2232
|
}
|
|
@@ -1444,6 +2255,19 @@ export class LSPService {
|
|
|
1444
2255
|
return undefined;
|
|
1445
2256
|
spawnPromise = started.promise;
|
|
1446
2257
|
}
|
|
2258
|
+
// Announce the in-flight spawn so the caller can skip a doomed touch
|
|
2259
|
+
// wait. The announcement never returns a client and never
|
|
2260
|
+
// short-circuits. A spawn that settles inside the race window is picked
|
|
2261
|
+
// up by getClientForFile, which re-reads `state.clients` at the point it
|
|
2262
|
+
// acts on the shortcut.
|
|
2263
|
+
//
|
|
2264
|
+
// Do NOT add a `state.clients` early return here. This point sits
|
|
2265
|
+
// downstream of the warm-reuse path, the dead-client shutdown, the #1127
|
|
2266
|
+
// give-up latch, the breaker cooldown, and the #1332 idle eviction, so a
|
|
2267
|
+
// return here re-publishes a client every one of those already declined.
|
|
2268
|
+
// It also skips the `finally` below that owns the `inFlight` entry,
|
|
2269
|
+
// which strands the settled promise and stops the server respawning.
|
|
2270
|
+
onSpawnInFlight?.(server.id);
|
|
1447
2271
|
try {
|
|
1448
2272
|
return await spawnPromise;
|
|
1449
2273
|
}
|
|
@@ -1549,7 +2373,9 @@ export class LSPService {
|
|
|
1549
2373
|
this.optionalFailureLogged.delete(key);
|
|
1550
2374
|
}
|
|
1551
2375
|
logSessionStart(`lsp spawn ${server.id}: success source=${spawned.source ?? "unknown"} (${Date.now() - startedAt}ms)`);
|
|
1552
|
-
|
|
2376
|
+
const spawnDurationMs = Date.now() - startedAt;
|
|
2377
|
+
recordLsp(server.id, root, "spawn_success", spawnDurationMs);
|
|
2378
|
+
recordSuccessfulLspSpawn(server.id, spawnDurationMs);
|
|
1553
2379
|
if (!this.workspaceProbeLogged.has(key)) {
|
|
1554
2380
|
logSessionStart(`lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`);
|
|
1555
2381
|
this.workspaceProbeLogged.add(key);
|
|
@@ -1587,9 +2413,19 @@ export class LSPService {
|
|
|
1587
2413
|
async openFile(filePath, content, options) {
|
|
1588
2414
|
if (this.checkDestroyed())
|
|
1589
2415
|
return;
|
|
2416
|
+
// #1783: anchored before the client acquisition, for the same reason
|
|
2417
|
+
// touchFile anchors on its own start — see recordFullyCoveredSync.
|
|
2418
|
+
const startedAt = Date.now();
|
|
1590
2419
|
await this.withClientForFileUse(filePath, undefined, options?.spawnBudgetMs, async (spawned) => {
|
|
1591
2420
|
const languageId = getLanguageId(filePath) ?? "plaintext";
|
|
1592
2421
|
await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
|
|
2422
|
+
// #1783: openFile is a real sync path — actionable-warnings and the
|
|
2423
|
+
// diagnostic-freshness callers reach a server through here and never
|
|
2424
|
+
// through touchFile. Without this, those documents were invisible to
|
|
2425
|
+
// the drift backstop. The same full-coverage gate applies, so an
|
|
2426
|
+
// auxiliary holding the document keeps the record unwritten rather
|
|
2427
|
+
// than letting one client's push claim every view is current.
|
|
2428
|
+
this.recordFullyCoveredSync(filePath, content, [spawned], true, startedAt);
|
|
1593
2429
|
});
|
|
1594
2430
|
}
|
|
1595
2431
|
/**
|
|
@@ -1605,20 +2441,58 @@ export class LSPService {
|
|
|
1605
2441
|
* and optionally collect diagnostics with explicit scope.
|
|
1606
2442
|
*/
|
|
1607
2443
|
async touchFile(filePath, content, options = {}) {
|
|
1608
|
-
if (this.checkDestroyed())
|
|
2444
|
+
if (this.checkDestroyed()) {
|
|
2445
|
+
// #1618: a destroyed service never reaches the language server — this
|
|
2446
|
+
// early return used to log nothing at all, so a workspace sweep whose
|
|
2447
|
+
// idle-reset timer fired mid-run left every remaining file with zero
|
|
2448
|
+
// trace, indistinguishable from budget exhaustion. Cheap and local: no
|
|
2449
|
+
// server round trip, matching the `no_clients`/`success` sibling
|
|
2450
|
+
// records this phase already emits below.
|
|
2451
|
+
const destroyedDiagnosticsMode = options.collectDiagnostics
|
|
2452
|
+
? (options.diagnostics ?? "document")
|
|
2453
|
+
: (options.diagnostics ?? "none");
|
|
2454
|
+
logLatency({
|
|
2455
|
+
type: "phase",
|
|
2456
|
+
phase: "lsp_touch_file",
|
|
2457
|
+
filePath: normalizeMapKey(filePath),
|
|
2458
|
+
durationMs: 0,
|
|
2459
|
+
metadata: {
|
|
2460
|
+
serverCountAttempted: 0,
|
|
2461
|
+
serverCountReady: 0,
|
|
2462
|
+
clientScope: options.clientScope ??
|
|
2463
|
+
(destroyedDiagnosticsMode === "full" ? "all" : "primary"),
|
|
2464
|
+
diagnosticsMode: destroyedDiagnosticsMode,
|
|
2465
|
+
source: options.source ?? "unknown",
|
|
2466
|
+
failureKind: "destroyed",
|
|
2467
|
+
},
|
|
2468
|
+
});
|
|
1609
2469
|
return;
|
|
2470
|
+
}
|
|
1610
2471
|
const startedAt = Date.now();
|
|
1611
2472
|
const normalizedPath = normalizeMapKey(filePath);
|
|
2473
|
+
// #1783: every path that asks a language server anything comes through
|
|
2474
|
+
// here, so this is where the disk-drift backstop gets its heartbeat.
|
|
2475
|
+
// Deliberately NOT awaited: the sweep is rate-limited to one pass per
|
|
2476
|
+
// 10s and runs alongside this touch's own client acquisition and
|
|
2477
|
+
// diagnostics wait, so it adds nothing to this call's latency. The
|
|
2478
|
+
// resync it may issue re-enters touchFile; every such re-entry happens
|
|
2479
|
+
// while the pass is still running, so the tracker's single-flight guard
|
|
2480
|
+
// returns the running pass and no recursion occurs. That guard is the
|
|
2481
|
+
// single mechanism — there is deliberately no second source-based check
|
|
2482
|
+
// here, which would be an unprovable duplicate of it.
|
|
2483
|
+
void this.sweepDocumentDrift().catch(() => { });
|
|
1612
2484
|
const diagnosticsMode = options.collectDiagnostics
|
|
1613
2485
|
? (options.diagnostics ?? "document")
|
|
1614
2486
|
: (options.diagnostics ?? "none");
|
|
1615
2487
|
const source = options.source ?? "unknown";
|
|
1616
2488
|
const clientScope = options.clientScope ?? (diagnosticsMode === "full" ? "all" : "primary");
|
|
1617
2489
|
const useAllClients = clientScope === "all";
|
|
2490
|
+
const resolvedPrimaryRoots = new Map();
|
|
2491
|
+
const waitSkipReasons = new Set();
|
|
1618
2492
|
let spawned;
|
|
1619
2493
|
let serverCountAttempted;
|
|
1620
2494
|
if (useAllClients) {
|
|
1621
|
-
const result = await this.getClientsForFile(filePath, options.excludeServerIds);
|
|
2495
|
+
const result = await this.getClientsForFile(filePath, options.excludeServerIds, resolvedPrimaryRoots);
|
|
1622
2496
|
spawned = result.clients;
|
|
1623
2497
|
serverCountAttempted = result.serverCountAttempted;
|
|
1624
2498
|
}
|
|
@@ -1626,14 +2500,14 @@ export class LSPService {
|
|
|
1626
2500
|
// Primary language server + the enabled cross-cutting auxiliaries
|
|
1627
2501
|
// (opengrep, …). The aggregation layer merges/dedups their diagnostics.
|
|
1628
2502
|
const [entry, aux] = await Promise.all([
|
|
1629
|
-
this.getClientForFile(filePath, options.maxClientWaitMs),
|
|
2503
|
+
this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons),
|
|
1630
2504
|
this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? [])),
|
|
1631
2505
|
]);
|
|
1632
2506
|
spawned = entry ? [entry, ...aux] : aux;
|
|
1633
2507
|
serverCountAttempted = spawned.length;
|
|
1634
2508
|
}
|
|
1635
2509
|
else {
|
|
1636
|
-
const entry = await this.getClientForFile(filePath, options.maxClientWaitMs);
|
|
2510
|
+
const entry = await this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons);
|
|
1637
2511
|
spawned = entry ? [entry] : [];
|
|
1638
2512
|
serverCountAttempted =
|
|
1639
2513
|
spawned.length > 0
|
|
@@ -1643,6 +2517,14 @@ export class LSPService {
|
|
|
1643
2517
|
: 0;
|
|
1644
2518
|
}
|
|
1645
2519
|
if (spawned.length === 0) {
|
|
2520
|
+
// A bounded caller can lose the client race while the single-flight spawn
|
|
2521
|
+
// it started is still progressing. Preserve that lifecycle evidence in the
|
|
2522
|
+
// touch verdict instead of reclassifying an empty ready set as absence.
|
|
2523
|
+
// `isSpawnInFlight` reads the spawn coordinator's own state and filters to
|
|
2524
|
+
// primary candidates, so this stays coupled to the dedupe mechanism.
|
|
2525
|
+
const failureKind = this.isSpawnInFlight(filePath, resolvedPrimaryRoots)
|
|
2526
|
+
? "spawn_in_flight_budget_elapsed"
|
|
2527
|
+
: "no_clients_none_spawning";
|
|
1646
2528
|
logLatency({
|
|
1647
2529
|
type: "phase",
|
|
1648
2530
|
phase: "lsp_touch_file",
|
|
@@ -1655,7 +2537,10 @@ export class LSPService {
|
|
|
1655
2537
|
diagnosticsMode,
|
|
1656
2538
|
source,
|
|
1657
2539
|
maxClientWaitMs: options.maxClientWaitMs,
|
|
1658
|
-
failureKind
|
|
2540
|
+
failureKind,
|
|
2541
|
+
...(waitSkipReasons.size > 0
|
|
2542
|
+
? { reason: [...waitSkipReasons][0] }
|
|
2543
|
+
: {}),
|
|
1659
2544
|
},
|
|
1660
2545
|
});
|
|
1661
2546
|
return;
|
|
@@ -1703,7 +2588,22 @@ export class LSPService {
|
|
|
1703
2588
|
// no-new-version baseline below.
|
|
1704
2589
|
const notifySkippedServerIds = new Set(spawnedServerIds.filter((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId)));
|
|
1705
2590
|
const notifySkipped = spawned.length > 0 && notifySkippedServerIds.size === spawned.length;
|
|
1706
|
-
|
|
2591
|
+
// #1531: the pre-notify diagnostics baseline for THIS file on each client.
|
|
2592
|
+
// It used to be `client.diagnosticsVersion`, a client-GLOBAL counter that also
|
|
2593
|
+
// advances for files this touch never mentions — which let a sibling file's
|
|
2594
|
+
// publication both end this file's wait early and read as an answer for it.
|
|
2595
|
+
// `getDiagnosticsVersionForPath` returns that same counter's value as of this
|
|
2596
|
+
// file's last publication, so every comparison downstream stays on one axis
|
|
2597
|
+
// while ignoring sibling paths. Captured here because the notify below clears
|
|
2598
|
+
// each client's cache for the file.
|
|
2599
|
+
//
|
|
2600
|
+
// The accessor is REQUIRED on `LSPClient`, so a real client always answers
|
|
2601
|
+
// with a number. The optional call is only so a hand-written test double that
|
|
2602
|
+
// predates it fails CLOSED — `undefined` keeps the existing "no usable
|
|
2603
|
+
// baseline" branch below and can never satisfy the evidence check — instead of
|
|
2604
|
+
// quietly reverting to the global counter, which is the defect itself.
|
|
2605
|
+
const readPathVersion = (client) => client.getDiagnosticsVersionForPath?.(filePath);
|
|
2606
|
+
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, readPathVersion(entry.client)]));
|
|
1707
2607
|
// #1458: read a late auxiliary publication BEFORE the ordinary resync
|
|
1708
2608
|
// clears its client cache. Carry it only when the publication's exact
|
|
1709
2609
|
// sent-content fingerprint matches this touch's content. A changed edit,
|
|
@@ -1711,17 +2611,77 @@ export class LSPService {
|
|
|
1711
2611
|
// replayed. The fresh notify still runs below, so scanners continue toward
|
|
1712
2612
|
// a publication for this touch while the prior late result reaches the read.
|
|
1713
2613
|
const touchContentHash = this.hashContent(content);
|
|
2614
|
+
// #1586: THE content-match atom. Every content-bound question in this touch
|
|
2615
|
+
// — the carry-over below, #1493's pre-notify snapshot, and the merge-time
|
|
2616
|
+
// coverage predicate — asks it here and nowhere else, so a door cannot
|
|
2617
|
+
// acquire a rule of its own by writing the comparison inline. A binding with
|
|
2618
|
+
// no `contentHash` (version-less publish) fails closed: `undefined` never
|
|
2619
|
+
// equals a hash.
|
|
2620
|
+
const bindingMatchesTouchContent = (binding) => binding?.contentHash === touchContentHash;
|
|
1714
2621
|
const carriedAuxiliary = options.collectDiagnostics
|
|
1715
2622
|
? spawned.flatMap((entry) => {
|
|
1716
2623
|
if (entry.info.role !== "auxiliary")
|
|
1717
2624
|
return [];
|
|
1718
2625
|
const binding = entry.client.getDiagnosticBinding?.(filePath);
|
|
1719
|
-
if (binding
|
|
2626
|
+
if (!bindingMatchesTouchContent(binding))
|
|
1720
2627
|
return [];
|
|
1721
2628
|
const diags = entry.client.getDiagnostics(filePath);
|
|
1722
2629
|
return diags.length > 0 ? [{ diags, binding }] : [];
|
|
1723
2630
|
})
|
|
1724
2631
|
: [];
|
|
2632
|
+
// #1493: auxiliaries whose STORED publication already covers exactly the
|
|
2633
|
+
// bytes this touch carries. Read BEFORE the notify below, which clears each
|
|
2634
|
+
// client's cache for the file. Unlike `carriedAuxiliary` this does not
|
|
2635
|
+
// require findings: an empty publication bound to this content is evidence
|
|
2636
|
+
// the scanner reported, which is what keeps a genuinely clean file clean
|
|
2637
|
+
// when its wait produces nothing new (a debounce-skipped notify, or a late
|
|
2638
|
+
// publication carried in from the previous touch).
|
|
2639
|
+
const auxPublishedThisContent = new Set(spawned.flatMap((entry) => entry.info.role === "auxiliary" &&
|
|
2640
|
+
bindingMatchesTouchContent(entry.client.getDiagnosticBinding?.(filePath))
|
|
2641
|
+
? [entry.info.id]
|
|
2642
|
+
: []));
|
|
2643
|
+
const spawnedByServerId = new Map(spawned.map((entry) => [entry.info.id, entry]));
|
|
2644
|
+
// #1549/#1586: does this auxiliary's publication describe exactly the bytes
|
|
2645
|
+
// this touch carries? THE coverage predicate — every door reads it, so a
|
|
2646
|
+
// scanner can never be named uncovered while its findings ride along in
|
|
2647
|
+
// `.diags`, or the reverse.
|
|
2648
|
+
//
|
|
2649
|
+
// It UNIONS two content-bound reads rather than replacing one with the other,
|
|
2650
|
+
// because they answer different questions:
|
|
2651
|
+
//
|
|
2652
|
+
// - `auxPublishedThisContent` was captured BEFORE the notify (#1493), because
|
|
2653
|
+
// a landed write clears the cache and would erase the evidence that the
|
|
2654
|
+
// scanner had already reported on these bytes.
|
|
2655
|
+
// - the read below is LIVE, and it catches the opposite race — #1459's own
|
|
2656
|
+
// documented signature: a write charged as timed out, or one the fan-out
|
|
2657
|
+
// gate deferred behind, that LANDS LATE, after which the scanner publishes
|
|
2658
|
+
// for this touch's content. Judging that auxiliary on the pre-notify
|
|
2659
|
+
// snapshot alone drops its CURRENT findings and names it uncovered — an
|
|
2660
|
+
// underclaim about a scanner that answered.
|
|
2661
|
+
//
|
|
2662
|
+
// Either match means covered; both are content-bound, so neither can pass off
|
|
2663
|
+
// another revision's findings as this touch's answer.
|
|
2664
|
+
//
|
|
2665
|
+
// WHEN it is asked is part of the rule. The live half moves over the life of
|
|
2666
|
+
// a touch, so two doors that ask at two instants can disagree — and the merge
|
|
2667
|
+
// ACTS on its answer by dropping findings, which a later answer cannot undo.
|
|
2668
|
+
// Every door that shares the merge's consequences therefore reads ONE frozen
|
|
2669
|
+
// evaluation (`auxCoveredAtMerge`, below), taken immediately before the merge
|
|
2670
|
+
// and never re-asked afterwards. The only callers of this function are that
|
|
2671
|
+
// freeze and the two aux wait-outcome producers, whose rows describe their own
|
|
2672
|
+
// instant and are reconciled against the freeze before anything is claimed.
|
|
2673
|
+
//
|
|
2674
|
+
// Everything it cannot speak for fails CLOSED — an id that never reached
|
|
2675
|
+
// `spawned` (a breaker-skipped scanner, which never attached) and any
|
|
2676
|
+
// primary-role server, whose findings are governed by #570's
|
|
2677
|
+
// timeout-preserves-last-known semantics rather than by this exemption.
|
|
2678
|
+
const auxCoversThisContent = (serverId) => {
|
|
2679
|
+
const entry = spawnedByServerId.get(serverId);
|
|
2680
|
+
if (entry?.info.role !== "auxiliary")
|
|
2681
|
+
return false;
|
|
2682
|
+
return (auxPublishedThisContent.has(serverId) ||
|
|
2683
|
+
bindingMatchesTouchContent(entry.client.getDiagnosticBinding?.(filePath)));
|
|
2684
|
+
};
|
|
1725
2685
|
// #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
|
|
1726
2686
|
// write gets its OWN notifyWriteBudgetMs budget rather than one shared
|
|
1727
2687
|
// deadline over a single Promise.all — otherwise one backpressured server
|
|
@@ -1736,8 +2696,22 @@ export class LSPService {
|
|
|
1736
2696
|
// server timed out"; this list carries the per-server detail the
|
|
1737
2697
|
// demonstratedReady gate reads so a healthy sibling stays eligible.
|
|
1738
2698
|
const notifyWriteTimedOutServerIds = [];
|
|
2699
|
+
// #1459: auxiliaries whose resync was DEFERRED because the gate already had
|
|
2700
|
+
// one outstanding write for that server. They carry no evidence about this
|
|
2701
|
+
// content, so they join the coverage gap below.
|
|
2702
|
+
const notifyDeferredServerIds = [];
|
|
1739
2703
|
if (!notifySkipped) {
|
|
1740
2704
|
const budget = notifyWriteBudgetMs();
|
|
2705
|
+
// #1459: how long a queued auxiliary may wait for its resync slot. Bounded
|
|
2706
|
+
// by the write budget AND by whatever the caller already declared it is
|
|
2707
|
+
// willing to spend on this touch (`maxClientWaitMs` — cascade's cold
|
|
2708
|
+
// snapshot passes 1000ms), minus what the client wait above already spent.
|
|
2709
|
+
// A flat write-budget wait would tax a caller that asked for less than one
|
|
2710
|
+
// budget in total. Non-positive means "no time left to queue": the server
|
|
2711
|
+
// is reported as uncovered immediately.
|
|
2712
|
+
const queueWaitMs = options.maxClientWaitMs !== undefined
|
|
2713
|
+
? Math.min(budget, Math.max(0, options.maxClientWaitMs - (Date.now() - startedAt)))
|
|
2714
|
+
: budget;
|
|
1741
2715
|
await Promise.all(spawned.map(async (entry) => {
|
|
1742
2716
|
// #743: this server already has this content from a recent touch
|
|
1743
2717
|
// that landed. Pushing again would clear its diagnostic cache for
|
|
@@ -1747,12 +2721,95 @@ export class LSPService {
|
|
|
1747
2721
|
return;
|
|
1748
2722
|
// Same identity as the broken/demonstratedReady maps.
|
|
1749
2723
|
const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
2724
|
+
// #1459: one outstanding resync per auxiliary. Primaries are
|
|
2725
|
+
// untouched — they serve one file per touch and are not the fan-out
|
|
2726
|
+
// target a `clientScope: "all"` sweep floods.
|
|
2727
|
+
const gated = entry.info.role === "auxiliary" && clientKey !== undefined;
|
|
2728
|
+
let slot;
|
|
2729
|
+
if (gated && clientKey) {
|
|
2730
|
+
// #1714: before taking the slot, make the server prove it
|
|
2731
|
+
// processed the notifies already sent. A sweep is sequential, so
|
|
2732
|
+
// the slot gate below is almost always free and cannot see a
|
|
2733
|
+
// backlog building. This never refuses the write — a scanner that
|
|
2734
|
+
// will not answer is latched past and left to #743's stall
|
|
2735
|
+
// machinery, which can demote and respawn it.
|
|
2736
|
+
const barrierStartedAt = Date.now();
|
|
2737
|
+
await this.paceAuxNotify(clientKey, entry, filePath, queueWaitMs, {
|
|
2738
|
+
source,
|
|
2739
|
+
clientScope,
|
|
2740
|
+
});
|
|
2741
|
+
// The barrier spends from the SAME budget the caller granted, so
|
|
2742
|
+
// the slot wait gets only what is left. Otherwise a paced touch
|
|
2743
|
+
// could cost two full budgets.
|
|
2744
|
+
const slotWaitMs = Math.max(0, queueWaitMs - (Date.now() - barrierStartedAt));
|
|
2745
|
+
const claim = await this.claimAuxNotifySlot(clientKey, entry, filePath, slotWaitMs);
|
|
2746
|
+
if ("outstandingMs" in claim) {
|
|
2747
|
+
// Queued behind a write the scanner has not accepted inside our
|
|
2748
|
+
// budget. Pushing anyway is what floods it, so this touch reports
|
|
2749
|
+
// the scanner as uncovered instead. The wedge timer armed with the
|
|
2750
|
+
// blocking write is what demotes a dead input path.
|
|
2751
|
+
notifyDeferredServerIds.push(entry.info.id);
|
|
2752
|
+
logLatency({
|
|
2753
|
+
type: "phase",
|
|
2754
|
+
phase: "lsp_notify_resync_deferred",
|
|
2755
|
+
filePath: normalizedPath,
|
|
2756
|
+
durationMs: claim.outstandingMs,
|
|
2757
|
+
metadata: {
|
|
2758
|
+
serverId: entry.info.id,
|
|
2759
|
+
source,
|
|
2760
|
+
clientScope,
|
|
2761
|
+
reason: "outstanding_write",
|
|
2762
|
+
outstandingMs: claim.outstandingMs,
|
|
2763
|
+
queueWaitMs,
|
|
2764
|
+
},
|
|
2765
|
+
});
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
slot = claim;
|
|
2769
|
+
}
|
|
1750
2770
|
let wrote;
|
|
1751
2771
|
let rejected = false;
|
|
1752
2772
|
try {
|
|
1753
|
-
|
|
2773
|
+
const writeStartedAt = Date.now();
|
|
2774
|
+
// Constructed inside the try so a client double without `notify`
|
|
2775
|
+
// (or any synchronous throw) still reads as a rejected write rather
|
|
2776
|
+
// than rejecting the whole per-file `Promise.all`.
|
|
2777
|
+
const writePromise = entry.client.notify
|
|
1754
2778
|
.open(filePath, content, languageId, undefined, silent)
|
|
1755
|
-
.then(() => true)
|
|
2779
|
+
.then(() => true);
|
|
2780
|
+
// #1714: the document is now in this auxiliary's input queue,
|
|
2781
|
+
// whether or not the write settles inside our budget. Counted here
|
|
2782
|
+
// so the next file sees the real backlog.
|
|
2783
|
+
if (gated && clientKey) {
|
|
2784
|
+
this.noteAuxNotifyIssued(clientKey, entry.client);
|
|
2785
|
+
}
|
|
2786
|
+
if (slot && clientKey) {
|
|
2787
|
+
const client = entry.client;
|
|
2788
|
+
const release = slot.release;
|
|
2789
|
+
// Release the slot on the write's OWN settle, whatever the caller
|
|
2790
|
+
// below decided to wait for. The handle is identity-checked, so a
|
|
2791
|
+
// demotion (which clears the map) or a later claim cannot be
|
|
2792
|
+
// released by this one.
|
|
2793
|
+
void writePromise.then(() => {
|
|
2794
|
+
release();
|
|
2795
|
+
// The write landed, just not inside the caller's budget —
|
|
2796
|
+
// retract the timeout it was charged for. A write that landed
|
|
2797
|
+
// IN budget took the success path below, which clears the
|
|
2798
|
+
// streak outright, so only the late case retracts. A landing
|
|
2799
|
+
// past the WEDGE window keeps its strike: at that point the
|
|
2800
|
+
// stall was long enough that #743's demotion is the honest
|
|
2801
|
+
// verdict, not a latency artifact.
|
|
2802
|
+
const outstandingMs = Date.now() - writeStartedAt;
|
|
2803
|
+
if (outstandingMs > budget && outstandingMs <= notifyWedgedMs()) {
|
|
2804
|
+
this.retractNotifyWriteBackpressure(clientKey, entry.info.id, filePath, outstandingMs, client);
|
|
2805
|
+
}
|
|
2806
|
+
}, release);
|
|
2807
|
+
}
|
|
2808
|
+
wrote = await withDeadline(writePromise, {
|
|
2809
|
+
ms: budget,
|
|
2810
|
+
onTimeout: "undefined",
|
|
2811
|
+
onReject: "propagate",
|
|
2812
|
+
});
|
|
1756
2813
|
}
|
|
1757
2814
|
catch {
|
|
1758
2815
|
// The write itself rejected (not backpressure): the content did
|
|
@@ -1760,6 +2817,10 @@ export class LSPService {
|
|
|
1760
2817
|
// rejection is not a stdin-backpressure signal and must not count
|
|
1761
2818
|
// toward the backpressure demotion streak.
|
|
1762
2819
|
rejected = true;
|
|
2820
|
+
// A synchronous throw (a client double without `notify`) never
|
|
2821
|
+
// reached the settle handlers that release the slot — release it
|
|
2822
|
+
// here so one bad client cannot wedge the queue. Idempotent.
|
|
2823
|
+
slot?.release();
|
|
1763
2824
|
}
|
|
1764
2825
|
if (wrote === true) {
|
|
1765
2826
|
// A clean write clears any accrued backpressure streak (#743).
|
|
@@ -1780,6 +2841,25 @@ export class LSPService {
|
|
|
1780
2841
|
}
|
|
1781
2842
|
}
|
|
1782
2843
|
}));
|
|
2844
|
+
// #1783: stamp the disk-drift record only when the touch achieved FULL
|
|
2845
|
+
// coverage — every targeted server's write landed AND no other live
|
|
2846
|
+
// client holds this document. The debounce entry above is per-server, so
|
|
2847
|
+
// stamping a per-FILE record inside that loop claimed a coverage the
|
|
2848
|
+
// touch may not have had: a primary-scoped touch leaves an auxiliary's
|
|
2849
|
+
// view untouched, and a touch where one server times out leaves that
|
|
2850
|
+
// server behind. Either way the sweep would then read "in sync" and stop
|
|
2851
|
+
// looking. On a partial touch the PREVIOUS record is deliberately kept:
|
|
2852
|
+
// its older `syncedAt` and older fingerprint keep the document eligible,
|
|
2853
|
+
// so the next sweep re-pushes it at full scope instead of going blind.
|
|
2854
|
+
//
|
|
2855
|
+
// BOTH exit lists, not just the timed-out one. The #1459 gate defers an
|
|
2856
|
+
// auxiliary whose previous write is still outstanding, and that server
|
|
2857
|
+
// leaves the write loop early without ever joining
|
|
2858
|
+
// `notifyWriteTimedOutServerIds`. Reading only that list let a deferred
|
|
2859
|
+
// scanner's untouched view be stamped as covered — the same laundering
|
|
2860
|
+
// through a different door.
|
|
2861
|
+
this.recordFullyCoveredSync(filePath, content, spawned, notifyWriteTimedOutServerIds.length === 0 &&
|
|
2862
|
+
notifyDeferredServerIds.length === 0, startedAt);
|
|
1783
2863
|
if (notifyWriteTimedOutServerIds.length > 0) {
|
|
1784
2864
|
logLatency({
|
|
1785
2865
|
type: "phase",
|
|
@@ -1795,14 +2875,50 @@ export class LSPService {
|
|
|
1795
2875
|
});
|
|
1796
2876
|
}
|
|
1797
2877
|
}
|
|
1798
|
-
// File-level flag: at least one server's write timed out
|
|
1799
|
-
//
|
|
2878
|
+
// File-level flag: at least one server's write timed out. Kept as the
|
|
2879
|
+
// observability summary (`lsp_touch_file.notifyWriteTimedOut`); the
|
|
2880
|
+
// `inconclusive` verdict reads the PRIMARY-scoped flag below (#1549).
|
|
1800
2881
|
const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
|
|
2882
|
+
// #1549: the honesty verdict is decided from the PRIMARY population only.
|
|
2883
|
+
// An auxiliary that missed a deadline is a named coverage gap, never an
|
|
2884
|
+
// inconclusive touch — see `resolveTouchVerdict` (diagnostic-binding.ts).
|
|
2885
|
+
const primaryEntries = spawned.filter((entry) => entry.info.role !== "auxiliary");
|
|
2886
|
+
const primaryServerIds = new Set(primaryEntries.map((e) => e.info.id));
|
|
2887
|
+
const primaryNotifyWriteTimedOutServerIds = notifyWriteTimedOutServerIds.filter((id) => primaryServerIds.has(id));
|
|
2888
|
+
const primaryNotifyWriteTimedOut = primaryNotifyWriteTimedOutServerIds.length > 0;
|
|
2889
|
+
// #1459: read by the diagnostics wait and the merge below — a deferred
|
|
2890
|
+
// server is neither waited on nor read from.
|
|
2891
|
+
const deferredResyncServerIds = new Set(notifyDeferredServerIds);
|
|
1801
2892
|
let diagnosticsTimedOut = false;
|
|
2893
|
+
// #1549: every server (any role) that produced no publication evidence when
|
|
2894
|
+
// the diagnostics wait lapsed. Read three ways: the primary members decide
|
|
2895
|
+
// `diagnosticsTimedOut` and are the `inconclusiveServerIds` attribution, the
|
|
2896
|
+
// auxiliary members join the coverage gap, and no member may be marked
|
|
2897
|
+
// `demonstratedReady`. Empty when the wait did not lapse.
|
|
2898
|
+
let diagnosticsUnansweredServerIds = [];
|
|
2899
|
+
// #1549: the primary subset of the list above, captured when the wait lapsed
|
|
2900
|
+
// so the attribution survives the silent-clean/sync gates clearing the flag.
|
|
2901
|
+
let diagnosticsUnansweredPrimaryServerIds = [];
|
|
2902
|
+
// #1549: a gate that certifies silence AS the answer (the tsserver sync
|
|
2903
|
+
// confirm, either silent-clean gate) retracts the primary attribution with it —
|
|
2904
|
+
// those servers answered, in the only way their capabilities allow, so they
|
|
2905
|
+
// stay eligible for `demonstratedReady` exactly as before this change.
|
|
2906
|
+
// Auxiliary members are left in place: nothing certified them, and they are
|
|
2907
|
+
// what the coverage gap reports.
|
|
2908
|
+
const retractPrimaryTimeoutAttribution = () => {
|
|
2909
|
+
diagnosticsUnansweredPrimaryServerIds = [];
|
|
2910
|
+
diagnosticsUnansweredServerIds = diagnosticsUnansweredServerIds.filter((id) => !primaryServerIds.has(id));
|
|
2911
|
+
};
|
|
1802
2912
|
// R8 (#714): server ids of aux-role servers whose push wait was cut off by
|
|
1803
2913
|
// the aux grace window. Undefined when no aux was cut off (primary-only
|
|
1804
2914
|
// paths never set this). Logged in lsp_touch_file metadata.
|
|
1805
2915
|
let auxCutOffServerIds;
|
|
2916
|
+
// #1493: aux-role servers this touch carries NO evidence from — the cut-off
|
|
2917
|
+
// set above PLUS the ones that stayed silent through their own budget with
|
|
2918
|
+
// no stored publication for this content. This is what narrows the
|
|
2919
|
+
// confirmation; `auxCutOffServerIds` stays cut_off-only so the R8 latency
|
|
2920
|
+
// field keeps its original meaning.
|
|
2921
|
+
let auxUnconfirmedServerIds;
|
|
1806
2922
|
// #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
|
|
1807
2923
|
// full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
|
|
1808
2924
|
// holds the sync commands' answer when the racing confirm won the wait
|
|
@@ -1957,7 +3073,13 @@ export class LSPService {
|
|
|
1957
3073
|
timeoutFor = () => callerCap ?? modeFloor;
|
|
1958
3074
|
}
|
|
1959
3075
|
// Detection deadline = the slowest individual server's budget.
|
|
1960
|
-
|
|
3076
|
+
// #1459: computed over the servers actually WAITED ON. A deferred server
|
|
3077
|
+
// contributes no wait, so including its (typically longest) scanner budget
|
|
3078
|
+
// here would raise the aggregate threshold above anything that can elapse
|
|
3079
|
+
// and mask a real timeout on the servers that did wait.
|
|
3080
|
+
const timeoutMs = Math.max(0, ...spawned
|
|
3081
|
+
.filter((e) => !deferredResyncServerIds.has(e.info.id))
|
|
3082
|
+
.map((e) => timeoutFor(e.client.serverId)));
|
|
1961
3083
|
// #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
|
|
1962
3084
|
// starts. Cheap synchronous gates first (notify succeeded, collecting,
|
|
1963
3085
|
// primary scope, `serverId === "typescript"` — the sync commands this
|
|
@@ -2027,21 +3149,51 @@ export class LSPService {
|
|
|
2027
3149
|
}
|
|
2028
3150
|
}
|
|
2029
3151
|
const perServerWaits = spawned.map((entry) => {
|
|
3152
|
+
// #1459: a DEFERRED server never received this content, so its version
|
|
3153
|
+
// can never advance past the baseline — waiting on it burns its whole
|
|
3154
|
+
// budget and would flip the touch to `inconclusive`, discarding a
|
|
3155
|
+
// primary answer that IS trustworthy. It contributes no wait; the
|
|
3156
|
+
// coverage gap below is what reports its absence.
|
|
3157
|
+
if (deferredResyncServerIds.has(entry.info.id)) {
|
|
3158
|
+
return Promise.resolve(undefined);
|
|
3159
|
+
}
|
|
2030
3160
|
const serverTimeout = timeoutFor(entry.client.serverId);
|
|
3161
|
+
// #1531: a per-path baseline. `clientWaitForDiagnostics` compares it
|
|
3162
|
+
// against this path's own publication stamp, so a sibling file's
|
|
3163
|
+
// publication on a shared client can no longer end this wait before the
|
|
3164
|
+
// server's own budget lapses — which is what kept the outcome labels
|
|
3165
|
+
// honest (`cut_off` means our grace won, `silent` means the server's own
|
|
3166
|
+
// budget lapsed with nothing published).
|
|
2031
3167
|
const baseline = diagnosticBaselines.get(entry.client);
|
|
2032
3168
|
const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
|
|
3169
|
+
// #1639: `ensureWarmForSweep`'s readiness probe (`source:
|
|
3170
|
+
// "lsp_sweep_warmup"`, `collectDiagnostics: false`) runs a real pull
|
|
3171
|
+
// round trip on this same file, then the sweep's real touch follows
|
|
3172
|
+
// immediately after — two legitimate settle observations for one
|
|
3173
|
+
// file, not a duplicate. Tag the warm-up one distinctly so a
|
|
3174
|
+
// consumer can tell them apart instead of double-counting. Omitted
|
|
3175
|
+
// (rather than passed as "pull") on the common path — the client
|
|
3176
|
+
// already defaults to "pull", and existing tests assert the exact
|
|
3177
|
+
// argument list `waitForDiagnostics` is called with.
|
|
3178
|
+
const isWarmupTouch = source === "lsp_sweep_warmup";
|
|
2033
3179
|
// #743: per-server — a server we DID push to still gets the
|
|
2034
3180
|
// version-baseline wait even when a sibling was debounced away.
|
|
2035
3181
|
const wait = !notifySkippedServerIds.has(entry.info.id) && Number.isFinite(baseline)
|
|
2036
3182
|
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
2037
3183
|
minVersion: baseline,
|
|
2038
3184
|
...(pullOnly && { pullOnly: true }),
|
|
3185
|
+
...(isWarmupTouch && { pullSettleSource: "pull-warmup" }),
|
|
2039
3186
|
})
|
|
2040
3187
|
: pullOnly
|
|
2041
3188
|
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
2042
3189
|
pullOnly: true,
|
|
3190
|
+
...(isWarmupTouch && { pullSettleSource: "pull-warmup" }),
|
|
2043
3191
|
})
|
|
2044
|
-
:
|
|
3192
|
+
: isWarmupTouch
|
|
3193
|
+
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
3194
|
+
pullSettleSource: "pull-warmup",
|
|
3195
|
+
})
|
|
3196
|
+
: entry.client.waitForDiagnostics(filePath, serverTimeout);
|
|
2045
3197
|
return wait.catch(() => undefined);
|
|
2046
3198
|
});
|
|
2047
3199
|
// The push wait — same per-server budget composition as before #707;
|
|
@@ -2112,17 +3264,43 @@ export class LSPService {
|
|
|
2112
3264
|
// NOT the same as having answered).
|
|
2113
3265
|
// - raced === true, evidence → "answered" (a fresh
|
|
2114
3266
|
// publication actually landed for this touch).
|
|
3267
|
+
//
|
|
3268
|
+
// #1531: the evidence is read PER PATH. The global
|
|
3269
|
+
// `diagnosticsVersion` advances for every file this client
|
|
3270
|
+
// publishes, so a concurrent touch of an unrelated file used to
|
|
3271
|
+
// hand this one an unearned "answered" row. The per-path stamp
|
|
3272
|
+
// carries the global counter's value at store time, so the
|
|
3273
|
+
// comparison stays monotonic across cache evictions while
|
|
3274
|
+
// ignoring sibling paths — and it is the SAME axis `baseline`
|
|
3275
|
+
// was captured on above.
|
|
3276
|
+
const currentPathVersion = readPathVersion(aux.client);
|
|
2115
3277
|
const publishedEvidence = raced &&
|
|
2116
3278
|
Number.isFinite(aux.baseline) &&
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
3279
|
+
currentPathVersion !== undefined &&
|
|
3280
|
+
currentPathVersion > aux.baseline;
|
|
3281
|
+
// #1459: a DEFERRED aux was never sent this content and is not
|
|
3282
|
+
// waited on at all, so its instantly-resolved placeholder
|
|
3283
|
+
// promise must not read as "silent". "Silent" is the reserved
|
|
3284
|
+
// signal for a scanner that HAD the content, finished inside
|
|
3285
|
+
// its own budget, and published nothing (#1493) — recording a
|
|
3286
|
+
// deferral there would corrupt the one row that tracks it.
|
|
3287
|
+
const outcome = deferredResyncServerIds.has(aux.serverId)
|
|
3288
|
+
? "deferred"
|
|
3289
|
+
: !raced
|
|
3290
|
+
? "cut_off"
|
|
3291
|
+
: publishedEvidence
|
|
3292
|
+
? "answered"
|
|
3293
|
+
: "silent";
|
|
2123
3294
|
return {
|
|
2124
3295
|
serverId: aux.serverId,
|
|
2125
3296
|
outcome,
|
|
3297
|
+
// #1493: carried into the coverage-gap policy so a silent
|
|
3298
|
+
// auxiliary that already published for these exact bytes is
|
|
3299
|
+
// not demoted. Logged too — it is the reason a `silent` row
|
|
3300
|
+
// did not narrow the touch.
|
|
3301
|
+
// #1586: through the one predicate, so this row and the merge
|
|
3302
|
+
// below cannot disagree about the same scanner.
|
|
3303
|
+
publishedThisContent: auxCoversThisContent(aux.serverId),
|
|
2126
3304
|
budgetMs,
|
|
2127
3305
|
elapsedMs: Date.now() - auxWaitStartedAt,
|
|
2128
3306
|
// #1458 S3: elapsed measured from BEFORE the primary wait
|
|
@@ -2138,12 +3316,23 @@ export class LSPService {
|
|
|
2138
3316
|
.map((outcome) => outcome.serverId);
|
|
2139
3317
|
if (unfinished.length > 0)
|
|
2140
3318
|
auxCutOffServerIds = unfinished;
|
|
3319
|
+
// #1493: one policy over both no-answer shapes. Lives in
|
|
3320
|
+
// diagnostic-binding.ts so no consumer re-derives the rule from
|
|
3321
|
+
// an outcome string.
|
|
3322
|
+
const uncovered = auxiliaryCoverageGap(outcomes);
|
|
3323
|
+
if (uncovered.length > 0)
|
|
3324
|
+
auxUnconfirmedServerIds = uncovered;
|
|
2141
3325
|
logLatency({
|
|
2142
3326
|
type: "phase",
|
|
2143
3327
|
phase: "lsp_aux_wait_outcome",
|
|
2144
3328
|
filePath: normalizedPath,
|
|
2145
3329
|
durationMs: Date.now() - auxWaitStartedAt,
|
|
2146
|
-
|
|
3330
|
+
// #1533: `waitShape` names the producer, because the aggregate
|
|
3331
|
+
// path emits the same row with the same outcome vocabulary
|
|
3332
|
+
// minus `cut_off`. A field query that sees only `silent` rows
|
|
3333
|
+
// must be able to tell "our ceiling was in play" from "the
|
|
3334
|
+
// auxiliary's own full budget lapsed".
|
|
3335
|
+
metadata: { clientScope, waitShape: "aux_grace", outcomes },
|
|
2147
3336
|
});
|
|
2148
3337
|
});
|
|
2149
3338
|
})()
|
|
@@ -2219,6 +3408,109 @@ export class LSPService {
|
|
|
2219
3408
|
await pushWait;
|
|
2220
3409
|
}
|
|
2221
3410
|
const waitedMs = Date.now() - waitStartedAt;
|
|
3411
|
+
// #1533: the same auxiliary coverage evidence for a collecting touch that
|
|
3412
|
+
// did NOT enter the aux-grace wait — in practice `clientScope: "all"`, the
|
|
3413
|
+
// batch/directory scan surface. Auxiliaries ARE spawned on that scope
|
|
3414
|
+
// (`getClientsForFile` returns every matching server, #573) and each one is
|
|
3415
|
+
// waited on inside `Promise.all(perServerWaits)` on its own per-server
|
|
3416
|
+
// budget, but `hasTouchAuxiliaries` is `with-auxiliary`-only, so no evidence
|
|
3417
|
+
// was ever derived and a silent scanner aggregated as an unqualified
|
|
3418
|
+
// `"confirmed"` — the #1493 false clean surviving on a different scope.
|
|
3419
|
+
//
|
|
3420
|
+
// NO SECOND WAIT. Every aux promise here has already settled (the
|
|
3421
|
+
// `Promise.all` above awaited it), so this reads post-wait state only. That
|
|
3422
|
+
// is deliberate: #1459's resync gate exists to ABSORB the aux fan-out of an
|
|
3423
|
+
// "all"-scope sweep into deferrals, and entering a per-neighbour aux grace
|
|
3424
|
+
// here would pay back the latency that gate just recovered. The evidence is
|
|
3425
|
+
// free; only the verdict changes.
|
|
3426
|
+
//
|
|
3427
|
+
// WHICH verdicts change, stated without overreach. Where the auxiliary's
|
|
3428
|
+
// budget is the MAX over waited servers (`perServerTimeout` is
|
|
3429
|
+
// `min(callerCap, strategyWait)` per server, `timeoutMs` is the max across
|
|
3430
|
+
// them), a silent auxiliary already tripped `diagnosticsTimedOut` and the
|
|
3431
|
+
// touch was already `inconclusive` — which is decided BEFORE the coverage
|
|
3432
|
+
// gap, so those results are unchanged. That covers opengrep on every current
|
|
3433
|
+
// per-edit path, whose 3500 exceeds either cap. But a FASTER auxiliary beside
|
|
3434
|
+
// a slower primary (typos 1500 or ast-grep 1800 next to rust-analyzer 3000
|
|
3435
|
+
// under a 2000 cap) settles inside `timeoutMs`, so nothing timed out and this
|
|
3436
|
+
// block genuinely narrows a result that used to read `confirmed`. That is the
|
|
3437
|
+
// fix working: the scanner said nothing about these bytes. It is fail-safe —
|
|
3438
|
+
// the primary's findings still ride along and only the coverage claim is
|
|
3439
|
+
// withdrawn — and the cost is a skipped cache seed for that file. Both cases
|
|
3440
|
+
// are pinned in `tests/clients/lsp/service-aux-grace.test.ts`.
|
|
3441
|
+
//
|
|
3442
|
+
// `cut_off` cannot arise on this path — there is no grace timer to end a
|
|
3443
|
+
// wait early — so the shapes are `answered` / `silent` / `deferred`, decided
|
|
3444
|
+
// by exactly the rules the grace path uses (#1458 S1: a settled promise is
|
|
3445
|
+
// not proof of a publication; only a `diagnosticsVersion` advance past the
|
|
3446
|
+
// pre-notify baseline is). `waitShape` distinguishes the two producers in
|
|
3447
|
+
// field data, since a `silent` row here means the auxiliary's own full
|
|
3448
|
+
// per-server budget lapsed rather than our ceiling cutting it short.
|
|
3449
|
+
//
|
|
3450
|
+
// A server the caller EXCLUDED (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`, #584)
|
|
3451
|
+
// never reaches `spawned`, so it cannot be reported here — an excluded-by-
|
|
3452
|
+
// design scanner is a routing decision, not a coverage gap, exactly as
|
|
3453
|
+
// `brokenSkippedAuxiliaryServerIds` already treats it.
|
|
3454
|
+
//
|
|
3455
|
+
// Written as `!hasTouchAuxiliaries` rather than `clientScope === "all"` so a
|
|
3456
|
+
// future scope that spawns auxiliaries without entering the grace wait fails
|
|
3457
|
+
// closed here by default instead of needing to be remembered. The #707
|
|
3458
|
+
// tsserver sync race can reach here with `pushWait` still pending, but it is
|
|
3459
|
+
// gated on `clientScope === "primary" && spawned.length === 1`, which spawns
|
|
3460
|
+
// no auxiliaries at all — so the per-role filter below is empty and no
|
|
3461
|
+
// evidence is read before its wait ends.
|
|
3462
|
+
//
|
|
3463
|
+
// `elapsedMs` and `elapsedSinceNotifyMs` are equal by construction here:
|
|
3464
|
+
// there is no separate post-primary aux phase to measure, so both describe
|
|
3465
|
+
// the one aggregate wait. Both fields are kept so a query can read either
|
|
3466
|
+
// producer's rows without special-casing the schema.
|
|
3467
|
+
//
|
|
3468
|
+
// The evidence is read PER PATH, through the same `readPathVersion` accessor
|
|
3469
|
+
// the grace path uses (#1531, landed on master while this was in review).
|
|
3470
|
+
// This is NOT interchangeable with `client.diagnosticsVersion`: that global
|
|
3471
|
+
// counter also advances for files this touch never mentions, so two
|
|
3472
|
+
// CONCURRENT touches sharing one auxiliary client cross-satisfy — a
|
|
3473
|
+
// publication for a.ts hands b.ts an unearned `answered`. That matters
|
|
3474
|
+
// especially here, because the highest-frequency `"all"` caller (the cascade
|
|
3475
|
+
// neighbour fan-out in `clients/dispatch/integration.ts`) is a
|
|
3476
|
+
// `Promise.allSettled` and its touches are always concurrent. Reading the
|
|
3477
|
+
// per-path stamp keeps this comparison on the SAME axis `baseline` was
|
|
3478
|
+
// captured on, and `undefined` from a double that predates the accessor fails
|
|
3479
|
+
// CLOSED rather than silently reverting to the global counter.
|
|
3480
|
+
if (!hasTouchAuxiliaries && options.collectDiagnostics === true) {
|
|
3481
|
+
const auxEntries = spawned.filter((entry) => entry.info.role === "auxiliary");
|
|
3482
|
+
if (auxEntries.length > 0) {
|
|
3483
|
+
const outcomes = auxEntries.map((entry) => {
|
|
3484
|
+
const baseline = diagnosticBaselines.get(entry.client);
|
|
3485
|
+
const currentPathVersion = readPathVersion(entry.client);
|
|
3486
|
+
const publishedEvidence = Number.isFinite(baseline) &&
|
|
3487
|
+
currentPathVersion !== undefined &&
|
|
3488
|
+
currentPathVersion > baseline;
|
|
3489
|
+
return {
|
|
3490
|
+
serverId: entry.info.id,
|
|
3491
|
+
outcome: deferredResyncServerIds.has(entry.info.id)
|
|
3492
|
+
? "deferred"
|
|
3493
|
+
: publishedEvidence
|
|
3494
|
+
? "answered"
|
|
3495
|
+
: "silent",
|
|
3496
|
+
publishedThisContent: auxCoversThisContent(entry.info.id),
|
|
3497
|
+
budgetMs: timeoutFor(entry.client.serverId),
|
|
3498
|
+
elapsedMs: waitedMs,
|
|
3499
|
+
elapsedSinceNotifyMs: waitedMs,
|
|
3500
|
+
};
|
|
3501
|
+
});
|
|
3502
|
+
const uncovered = auxiliaryCoverageGap(outcomes);
|
|
3503
|
+
if (uncovered.length > 0)
|
|
3504
|
+
auxUnconfirmedServerIds = uncovered;
|
|
3505
|
+
logLatency({
|
|
3506
|
+
type: "phase",
|
|
3507
|
+
phase: "lsp_aux_wait_outcome",
|
|
3508
|
+
filePath: normalizedPath,
|
|
3509
|
+
durationMs: waitedMs,
|
|
3510
|
+
metadata: { clientScope, waitShape: "aggregate", outcomes },
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
2222
3514
|
if (tsserverSyncConfirmed !== undefined) {
|
|
2223
3515
|
// #707: the racing sync confirm won — a definitive answer well under
|
|
2224
3516
|
// the push-wait budget. Not a timeout, not inconclusive.
|
|
@@ -2243,8 +3535,59 @@ export class LSPService {
|
|
|
2243
3535
|
// Within ~20 ms of the configured budget we treat it as a timeout;
|
|
2244
3536
|
// the LSP didn't beat the cap. Diagnostics that arrive late still
|
|
2245
3537
|
// land in the client's cache and surface on the next edit.
|
|
2246
|
-
|
|
2247
|
-
|
|
3538
|
+
//
|
|
3539
|
+
// #1549: WHOSE budget lapsed decides the verdict. `timeoutMs` is the MAX
|
|
3540
|
+
// over the servers waited on, so a slow auxiliary (opengrep declares
|
|
3541
|
+
// 3500ms) sets the aggregate deadline for the whole touch — and a
|
|
3542
|
+
// touch-wide `diagnosticsTimedOut = true` then discarded a primary answer
|
|
3543
|
+
// that landed in 100ms. Attribute the lapse per server instead: the
|
|
3544
|
+
// touch is inconclusive only when a PRIMARY produced no evidence; an
|
|
3545
|
+
// auxiliary that produced none becomes a named coverage gap below.
|
|
3546
|
+
//
|
|
3547
|
+
// Evidence, not promise settlement (#1458 S1): `waitForDiagnostics`
|
|
3548
|
+
// resolves on its own timeout, so a settled wait proves nothing. The
|
|
3549
|
+
// per-path publication stamp advancing past this touch's pre-notify
|
|
3550
|
+
// baseline (#1531) is the primary signal; a present per-file cache entry
|
|
3551
|
+
// is the second, because the notify this touch just sent cleared that
|
|
3552
|
+
// entry (`clearDiagnosticsForPath`), so a present one can only be a fresh
|
|
3553
|
+
// answer — the same signal #814's aggregate gate already trusts.
|
|
3554
|
+
//
|
|
3555
|
+
// Every unknown fails CLOSED: a client that exposes neither accessor
|
|
3556
|
+
// reads as unanswered, which for a primary is exactly the pre-#1549
|
|
3557
|
+
// verdict. This block can therefore only ever NARROW an inconclusive
|
|
3558
|
+
// touch, never create one.
|
|
3559
|
+
const answeredForThisTouch = (entry) => {
|
|
3560
|
+
const baseline = diagnosticBaselines.get(entry.client);
|
|
3561
|
+
const currentPathVersion = readPathVersion(entry.client);
|
|
3562
|
+
if (Number.isFinite(baseline) &&
|
|
3563
|
+
currentPathVersion !== undefined &&
|
|
3564
|
+
currentPathVersion > baseline) {
|
|
3565
|
+
return true;
|
|
3566
|
+
}
|
|
3567
|
+
try {
|
|
3568
|
+
return entry.client.getAllDiagnostics?.().has(normalizedPath) === true;
|
|
3569
|
+
}
|
|
3570
|
+
catch {
|
|
3571
|
+
// Fail closed: an unreadable cache is not evidence of an answer.
|
|
3572
|
+
return false;
|
|
3573
|
+
}
|
|
3574
|
+
};
|
|
3575
|
+
// A deferred server was never sent this content and is not waited on, so
|
|
3576
|
+
// it cannot have "timed out" — it is already reported as a coverage gap.
|
|
3577
|
+
const waited = spawned.filter((entry) => !deferredResyncServerIds.has(entry.info.id));
|
|
3578
|
+
const unanswered = waited.filter((entry) => !answeredForThisTouch(entry));
|
|
3579
|
+
diagnosticsUnansweredServerIds = unanswered.map((e) => e.info.id);
|
|
3580
|
+
diagnosticsUnansweredPrimaryServerIds = unanswered
|
|
3581
|
+
.filter((entry) => entry.info.role !== "auxiliary")
|
|
3582
|
+
.map((e) => e.info.id);
|
|
3583
|
+
// Fail-safe: a touch with no waited-on primary has no primary answer to
|
|
3584
|
+
// preserve, so it keeps the pre-#1549 touch-wide verdict rather than
|
|
3585
|
+
// absolving itself on an auxiliary's evidence.
|
|
3586
|
+
const hasWaitedPrimary = waited.some((entry) => entry.info.role !== "auxiliary");
|
|
3587
|
+
diagnosticsTimedOut =
|
|
3588
|
+
!hasWaitedPrimary ||
|
|
3589
|
+
diagnosticsUnansweredPrimaryServerIds.length > 0;
|
|
3590
|
+
for (const entry of unanswered) {
|
|
2248
3591
|
incrementDegradationCount({
|
|
2249
3592
|
kind: "lsp-diagnostics-timeout",
|
|
2250
3593
|
// `info.id` is the authoritative server identity carried by
|
|
@@ -2272,6 +3615,13 @@ export class LSPService {
|
|
|
2272
3615
|
clientScope,
|
|
2273
3616
|
diagnosticsMode,
|
|
2274
3617
|
timeoutMs,
|
|
3618
|
+
// #1549: which of those servers actually produced no evidence, and
|
|
3619
|
+
// whether the lapse is attributable to a primary (the touch is
|
|
3620
|
+
// inconclusive) or only to auxiliaries (a named coverage gap, with
|
|
3621
|
+
// the primary's findings intact). Without these two fields a
|
|
3622
|
+
// forensic sweep cannot tell the two apart at all.
|
|
3623
|
+
unansweredServerIds: diagnosticsUnansweredServerIds,
|
|
3624
|
+
attributedToPrimary: diagnosticsTimedOut,
|
|
2275
3625
|
},
|
|
2276
3626
|
});
|
|
2277
3627
|
}
|
|
@@ -2308,9 +3658,20 @@ export class LSPService {
|
|
|
2308
3658
|
// must have actually landed" conservatism #799 established — a
|
|
2309
3659
|
// server's silence is only evidence of "clean" when we know it saw
|
|
2310
3660
|
// the new content.
|
|
2311
|
-
|
|
3661
|
+
//
|
|
3662
|
+
// #1549: both the gate and its "still outstanding" set are PRIMARY-scoped.
|
|
3663
|
+
// An auxiliary is never asked to prove itself tier3-silent here, because an
|
|
3664
|
+
// auxiliary that never reported is already named as a coverage gap — and
|
|
3665
|
+
// requiring it to was the second half of the touch-wide conflation: a clean
|
|
3666
|
+
// markdown file whose marksman silence IS the answer stayed inconclusive
|
|
3667
|
+
// purely because an opengrep scan beside it had not finished. The auxiliary's
|
|
3668
|
+
// absence still costs the touch its full confirmation (`partial`); what it no
|
|
3669
|
+
// longer does is erase the primary's answer.
|
|
3670
|
+
if (diagnosticsTimedOut &&
|
|
3671
|
+
!primaryNotifyWriteTimedOut &&
|
|
3672
|
+
clientScope === "all") {
|
|
2312
3673
|
try {
|
|
2313
|
-
const outstanding =
|
|
3674
|
+
const outstanding = primaryEntries.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
|
|
2314
3675
|
!entry.client.getAllDiagnostics().has(normalizedPath));
|
|
2315
3676
|
if (outstanding.length > 0) {
|
|
2316
3677
|
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
@@ -2329,6 +3690,7 @@ export class LSPService {
|
|
|
2329
3690
|
const liveness = await Promise.all(outstanding.map((entry) => (entry.client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false)));
|
|
2330
3691
|
if (liveness.every(Boolean)) {
|
|
2331
3692
|
diagnosticsTimedOut = false;
|
|
3693
|
+
retractPrimaryTimeoutAttribution(); // #1549
|
|
2332
3694
|
logLatency({
|
|
2333
3695
|
type: "phase",
|
|
2334
3696
|
phase: "lsp_silent_clean_confirm",
|
|
@@ -2352,6 +3714,55 @@ export class LSPService {
|
|
|
2352
3714
|
}
|
|
2353
3715
|
}
|
|
2354
3716
|
}
|
|
3717
|
+
// #1586: THE coverage evaluation, taken ONCE, here — the last statement before
|
|
3718
|
+
// the merge, with no `await` between it and the drop it authorizes. Everything
|
|
3719
|
+
// that shares the merge's consequences reads this frozen set and never asks
|
|
3720
|
+
// the live predicate again.
|
|
3721
|
+
//
|
|
3722
|
+
// The review round on this change proved why the freeze has to be the unit.
|
|
3723
|
+
// `touchFile` awaits after the merge — `brokenSkippedAuxiliaryServerIds` on
|
|
3724
|
+
// every collecting touch, the tsserver sync and liveness gates on theirs — and
|
|
3725
|
+
// a publication landing in that window flips the live predicate. Re-asking it
|
|
3726
|
+
// when the coverage gap was named then un-named a scanner whose findings the
|
|
3727
|
+
// merge had ALREADY dropped: `.diags` missing the scanner's answer while the
|
|
3728
|
+
// touch claimed `confirmed`, which unblocks the `lastKnownDiagnostics` prime
|
|
3729
|
+
// and the `demonstratedReady` mark that `coverageGap` exists to hold shut.
|
|
3730
|
+
// That is #1459's blackout reading as scanned-clean — the overclaim direction,
|
|
3731
|
+
// and the worse one. A drop is an action; a later answer cannot undo it, so
|
|
3732
|
+
// the naming must be settled from the same instant that authorized it.
|
|
3733
|
+
const auxCoveredAtMerge = new Set(spawned
|
|
3734
|
+
.filter((entry) => auxCoversThisContent(entry.info.id))
|
|
3735
|
+
.map((entry) => entry.info.id));
|
|
3736
|
+
// Which DEFERRED auxiliaries this touch genuinely carries no evidence from.
|
|
3737
|
+
// The deferral itself only proves the gate did not send these bytes on THIS
|
|
3738
|
+
// touch; whether the scanner has reported on them is a content-hash question.
|
|
3739
|
+
const uncoveredDeferredServerIds = notifyDeferredServerIds.filter((serverId) => !auxCoveredAtMerge.has(serverId));
|
|
3740
|
+
// An AUXILIARY whose notify write never landed still holds the previous
|
|
3741
|
+
// content's findings — nothing cleared its cache — and before this change that
|
|
3742
|
+
// touch was blanket `inconclusive`, so no consumer read the merged array. Now
|
|
3743
|
+
// the primary's answer flows, which means the auxiliary's stale findings would
|
|
3744
|
+
// flow with it and be reported (with the previous revision's line numbers) as
|
|
3745
|
+
// this touch's answer. Drop them; the write failure is reported as a coverage
|
|
3746
|
+
// gap instead.
|
|
3747
|
+
// Auxiliaries only. A PRIMARY keeps #570's deliberate
|
|
3748
|
+
// timeout-preserves-last-known-diagnostics semantics, and its write failure
|
|
3749
|
+
// makes the touch inconclusive anyway, so no consumer reads the array as
|
|
3750
|
+
// current.
|
|
3751
|
+
const staleWriteAuxiliaryServerIds = spawned
|
|
3752
|
+
.filter((entry) => entry.info.role === "auxiliary" &&
|
|
3753
|
+
notifyWriteTimedOutServerIds.includes(entry.info.id) &&
|
|
3754
|
+
!auxCoveredAtMerge.has(entry.info.id))
|
|
3755
|
+
.map((entry) => entry.info.id);
|
|
3756
|
+
// #1586: every contribution this merge withholds, in one set. The merged
|
|
3757
|
+
// BINDING reads it too (#1459's door, which filtered the raw deferral set and
|
|
3758
|
+
// so excluded the fingerprint of a deferred-but-covered scanner whose findings
|
|
3759
|
+
// the merge had just kept) — a dropped contributor must lose its findings and
|
|
3760
|
+
// its binding together, or the merged `boundToCurrentDisk` describes bytes the
|
|
3761
|
+
// result no longer contains.
|
|
3762
|
+
const droppedAuxiliaryServerIds = new Set([
|
|
3763
|
+
...uncoveredDeferredServerIds,
|
|
3764
|
+
...staleWriteAuxiliaryServerIds,
|
|
3765
|
+
]);
|
|
2355
3766
|
// #707: when the racing sync confirm won the wait, its answer IS the
|
|
2356
3767
|
// collected result — the file's real syntactic + semantic state straight
|
|
2357
3768
|
// from tsserver (clean = [], dirty = real findings that a silentOnClean
|
|
@@ -2361,7 +3772,17 @@ export class LSPService {
|
|
|
2361
3772
|
? tsserverSyncConfirmed !== undefined
|
|
2362
3773
|
? mergeLspDiagnostics(tsserverSyncConfirmed)
|
|
2363
3774
|
: mergeLspDiagnostics([
|
|
2364
|
-
|
|
3775
|
+
// #1459: a DEFERRED server's cache still holds the PREVIOUS
|
|
3776
|
+
// content's findings — the resync that would have cleared it never
|
|
3777
|
+
// ran. Merging them would report another revision's findings (and
|
|
3778
|
+
// its line numbers) as this touch's answer, the one hazard the
|
|
3779
|
+
// gate itself creates. Drop them; the gap is reported instead.
|
|
3780
|
+
// #1586: unless the scanner has since published for exactly these
|
|
3781
|
+
// bytes — `droppedAuxiliaryServerIds` is the one frozen answer the
|
|
3782
|
+
// coverage naming and the merged binding read too.
|
|
3783
|
+
...spawned.flatMap((entry) => droppedAuxiliaryServerIds.has(entry.info.id)
|
|
3784
|
+
? []
|
|
3785
|
+
: entry.client.getDiagnostics(filePath)),
|
|
2365
3786
|
...carriedAuxiliary.flatMap((entry) => entry.diags),
|
|
2366
3787
|
])
|
|
2367
3788
|
: undefined;
|
|
@@ -2399,6 +3820,7 @@ export class LSPService {
|
|
|
2399
3820
|
// Sync answered — confirmed result (clean or with diagnostics).
|
|
2400
3821
|
// Clear the timed-out flag so the touch is no longer inconclusive.
|
|
2401
3822
|
diagnosticsTimedOut = false;
|
|
3823
|
+
retractPrimaryTimeoutAttribution(); // #1549
|
|
2402
3824
|
syncConfirmed = true;
|
|
2403
3825
|
collected = syncResult.length > 0
|
|
2404
3826
|
? mergeLspDiagnostics(syncResult)
|
|
@@ -2457,7 +3879,10 @@ export class LSPService {
|
|
|
2457
3879
|
// into the general one once both have soaked, but that's a separate,
|
|
2458
3880
|
// lower-risk follow-up, not bundled into this fix.
|
|
2459
3881
|
if (diagnosticsTimedOut &&
|
|
2460
|
-
|
|
3882
|
+
// #1549: primary-scoped, like the aggregate gate. `spawned.length === 1`
|
|
3883
|
+
// below means the one server IS the primary, so this is the same condition
|
|
3884
|
+
// written in the vocabulary the rest of the merge now uses.
|
|
3885
|
+
!primaryNotifyWriteTimedOut &&
|
|
2461
3886
|
!tsserverSyncEligible &&
|
|
2462
3887
|
clientScope === "primary" &&
|
|
2463
3888
|
spawned.length === 1 &&
|
|
@@ -2474,6 +3899,7 @@ export class LSPService {
|
|
|
2474
3899
|
const alive = await (spawned[0].client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false);
|
|
2475
3900
|
if (alive) {
|
|
2476
3901
|
diagnosticsTimedOut = false;
|
|
3902
|
+
retractPrimaryTimeoutAttribution(); // #1549
|
|
2477
3903
|
if (collected !== undefined)
|
|
2478
3904
|
collected = mergeLspDiagnostics([]);
|
|
2479
3905
|
logLatency({
|
|
@@ -2496,32 +3922,138 @@ export class LSPService {
|
|
|
2496
3922
|
// behavior.
|
|
2497
3923
|
}
|
|
2498
3924
|
}
|
|
2499
|
-
//
|
|
2500
|
-
// diagnostics wait hit their deadline
|
|
2501
|
-
//
|
|
2502
|
-
//
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2505
|
-
//
|
|
2506
|
-
//
|
|
2507
|
-
//
|
|
2508
|
-
|
|
2509
|
-
//
|
|
2510
|
-
//
|
|
2511
|
-
//
|
|
2512
|
-
//
|
|
2513
|
-
//
|
|
2514
|
-
//
|
|
2515
|
-
//
|
|
2516
|
-
//
|
|
3925
|
+
// #1549: a touch is inconclusive when a PRIMARY's notify write or the
|
|
3926
|
+
// diagnostics wait hit their deadline. Both inputs are primary-scoped now.
|
|
3927
|
+
//
|
|
3928
|
+
// The rule this replaced — `notifyWriteTimedOut || diagnosticsTimedOut`, both
|
|
3929
|
+
// flags touch-wide over every spawned server — discarded every good answer in
|
|
3930
|
+
// the touch whenever one auxiliary was slow. `timeoutMs` is the MAX over the
|
|
3931
|
+
// servers waited on, so opengrep's 3500ms budget set the deadline for the whole
|
|
3932
|
+
// touch and a typescript answer that landed in 100ms read as "nothing is known
|
|
3933
|
+
// about this file". Measured over 6,079 cascade neighbour sweeps: 97.6%
|
|
3934
|
+
// inconclusive, against 15% for ordinary edit-time touches in the same window.
|
|
3935
|
+
//
|
|
3936
|
+
// The caution the old comment argued for is preserved, in the honest place: the
|
|
3937
|
+
// merged result IS missing whatever the unreporting auxiliary would have said,
|
|
3938
|
+
// so the touch withdraws its claim of full coverage (`confirmation: "partial"`
|
|
3939
|
+
// plus `unconfirmedServerIds`, below) and every consumer that treats
|
|
3940
|
+
// confirmation as proof of coverage still fails closed. What it no longer does
|
|
3941
|
+
// is throw away the primary's answer, which is the #533 honesty doctrine
|
|
3942
|
+
// cutting both ways.
|
|
3943
|
+
const verdict = resolveTouchVerdict({
|
|
3944
|
+
primaryNotifyWriteTimedOutServerIds,
|
|
3945
|
+
diagnosticsTimedOut,
|
|
3946
|
+
diagnosticsUnansweredServerIds: diagnosticsUnansweredPrimaryServerIds,
|
|
3947
|
+
});
|
|
3948
|
+
const inconclusive = verdict.inconclusive;
|
|
3949
|
+
// #1470/#1493: an auxiliary whose push wait was CUT OFF by the aux grace
|
|
3950
|
+
// timer (R8/#714) contributed exactly as much evidence about this file as one
|
|
3951
|
+
// that went silent inside its own budget — none. Both now narrow the
|
|
3952
|
+
// confirmation, through the one `auxiliaryCoverageGap` policy. A hung or
|
|
3953
|
+
// silent opengrep used to resolve `confirmation: "confirmed"` and read as
|
|
3954
|
+
// confirmed-clean on the security lane; the silent half survived #1470
|
|
3955
|
+
// because it only tripped `diagnosticsTimedOut` when it was the ONLY
|
|
3956
|
+
// auxiliary, so a fast sibling hid it (#1493).
|
|
2517
3957
|
// This does NOT flip the touch to inconclusive: that would discard a
|
|
2518
3958
|
// primary answer that IS trustworthy (#533 honesty doctrine cuts both ways —
|
|
2519
3959
|
// overclaiming and underclaiming are both dishonest). Instead the confirmation
|
|
2520
3960
|
// is NARROWED: `"partial"`, naming the servers it does not speak for, so every
|
|
2521
3961
|
// consumer that treats confirmation as proof of coverage fails closed while
|
|
2522
3962
|
// the primary's findings still flow.
|
|
2523
|
-
|
|
3963
|
+
// #1459: two more doors into the same room, and they open BEFORE any wait —
|
|
3964
|
+
// so `auxiliaryCoverageGap` (which reads wait outcomes) cannot see either on
|
|
3965
|
+
// its own. A scanner whose breaker was open never attached, and one whose
|
|
3966
|
+
// resync the fan-out gate deferred never received this content.
|
|
3967
|
+
//
|
|
3968
|
+
// The deferred ids are unioned in rather than left to the aux-wait policy on
|
|
3969
|
+
// purpose: an aux outcome row requires an auxiliary to have been SPAWNED, and a
|
|
3970
|
+
// breaker-skipped scanner never was. #1533: an `"all"`-scope sweep emits outcome
|
|
3971
|
+
// rows now too, so a spawned-but-deferred server arrives through BOTH routes as
|
|
3972
|
+
// outcome `"deferred"` — where a stored publication
|
|
3973
|
+
// for these exact bytes can still exempt it — so the Set dedups rather than
|
|
3974
|
+
// double-reports, and #1493's content-hash exemption is not bypassed here:
|
|
3975
|
+
// a deferred aux is only unioned in because the gate itself proves it was
|
|
3976
|
+
// never sent this content.
|
|
3977
|
+
const brokenSkippedServerIds = collected !== undefined
|
|
3978
|
+
? await this.brokenSkippedAuxiliaryServerIds(filePath, clientScope, options, spawned)
|
|
3979
|
+
: [];
|
|
3980
|
+
// #1549: the fourth door, and the one this issue opened. An auxiliary that
|
|
3981
|
+
// missed a deadline no longer makes the touch inconclusive, so it MUST arrive
|
|
3982
|
+
// here instead — otherwise the fix would launder a scanner blackout into a
|
|
3983
|
+
// clean bill of health, which is the #1459/#1493 false-clean pointing the other
|
|
3984
|
+
// way. Two shapes reach this and no aux outcome row: an auxiliary whose notify
|
|
3985
|
+
// write timed out or rejected, and one that produced no publication evidence
|
|
3986
|
+
// when the wait lapsed (including on a NON-collecting touch, which derives no
|
|
3987
|
+
// outcome rows at all). The content-bound exemption is honored through the same
|
|
3988
|
+
// `auxCoversThisContent` predicate the merge uses — one rule, so a scanner
|
|
3989
|
+
// cannot be named uncovered while its findings ride along in `.diags`, or the
|
|
3990
|
+
// reverse. The Set dedups against the ids `auxiliaryCoverageGap` reported.
|
|
3991
|
+
const auxNoAnswerServerIds = spawned
|
|
3992
|
+
.filter((entry) => entry.info.role === "auxiliary" &&
|
|
3993
|
+
(diagnosticsUnansweredServerIds.includes(entry.info.id) ||
|
|
3994
|
+
notifyWriteTimedOutServerIds.includes(entry.info.id)) &&
|
|
3995
|
+
!auxCoveredAtMerge.has(entry.info.id))
|
|
3996
|
+
.map((entry) => entry.info.id);
|
|
3997
|
+
// #1586: whatever the doors contributed, the RESULT's coverage claim is
|
|
3998
|
+
// settled from the MERGE's frozen evaluation — never a fresh one, which is
|
|
3999
|
+
// what made this an overclaim in review (see `auxCoveredAtMerge`). The
|
|
4000
|
+
// per-door filters above are not redundant with this one: they shape the
|
|
4001
|
+
// `lsp_scanner_coverage_gap` fields, which each answer "what did THIS door
|
|
4002
|
+
// see", while this settles "what does the touch speak for". It matters most
|
|
4003
|
+
// for `auxUnconfirmedServerIds`, decided when the aux wait ended and therefore
|
|
4004
|
+
// strictly BEFORE the merge — reconciling it here is what keeps a scanner from
|
|
4005
|
+
// being named while the merge kept its findings. A breaker-skipped scanner
|
|
4006
|
+
// never reached `spawned`, so it is not in the covered set and stays named:
|
|
4007
|
+
// fail closed.
|
|
4008
|
+
const unconfirmedServerIds = [
|
|
4009
|
+
...new Set([
|
|
4010
|
+
...(auxUnconfirmedServerIds ?? []),
|
|
4011
|
+
...auxNoAnswerServerIds,
|
|
4012
|
+
...uncoveredDeferredServerIds,
|
|
4013
|
+
...brokenSkippedServerIds,
|
|
4014
|
+
]),
|
|
4015
|
+
].filter((serverId) => !auxCoveredAtMerge.has(serverId));
|
|
2524
4016
|
const coverageGap = unconfirmedServerIds.length > 0;
|
|
4017
|
+
// The record that proves a blackout is no longer read as clean: one row per
|
|
4018
|
+
// touch that a scanner did not cover, naming the scanner and the reason.
|
|
4019
|
+
if (brokenSkippedServerIds.length > 0 ||
|
|
4020
|
+
uncoveredDeferredServerIds.length > 0 ||
|
|
4021
|
+
// #1549: the auxiliary deadline misses that used to surface as a blanket
|
|
4022
|
+
// `inconclusive` need their own row now that the touch reports usable
|
|
4023
|
+
// findings — otherwise the fix would remove the only record of the blackout.
|
|
4024
|
+
auxNoAnswerServerIds.length > 0) {
|
|
4025
|
+
for (const serverId of unconfirmedServerIds) {
|
|
4026
|
+
const reasons = [
|
|
4027
|
+
brokenSkippedServerIds.includes(serverId) && "breaker skip",
|
|
4028
|
+
uncoveredDeferredServerIds.includes(serverId) && "deferred resync",
|
|
4029
|
+
auxNoAnswerServerIds.includes(serverId) && "no diagnostics answer",
|
|
4030
|
+
].filter(Boolean);
|
|
4031
|
+
incrementDegradationCount({
|
|
4032
|
+
kind: "lsp-scanner-coverage-gap",
|
|
4033
|
+
subject: `${serverId}:${normalizedPath}`,
|
|
4034
|
+
reason: reasons.join(", ") || "scanner coverage gap",
|
|
4035
|
+
});
|
|
4036
|
+
}
|
|
4037
|
+
logLatency({
|
|
4038
|
+
type: "phase",
|
|
4039
|
+
phase: "lsp_scanner_coverage_gap",
|
|
4040
|
+
filePath: normalizedPath,
|
|
4041
|
+
durationMs: Date.now() - startedAt,
|
|
4042
|
+
metadata: {
|
|
4043
|
+
source,
|
|
4044
|
+
clientScope,
|
|
4045
|
+
...(brokenSkippedServerIds.length > 0 && { brokenSkippedServerIds }),
|
|
4046
|
+
// #1586: the deferrals this touch is actually uncovered for. The raw
|
|
4047
|
+
// gate action keeps its own record in `lsp_notify_resync_deferred`;
|
|
4048
|
+
// this row exists to prove a blackout, and a scanner already bound to
|
|
4049
|
+
// these bytes is not one.
|
|
4050
|
+
...(uncoveredDeferredServerIds.length > 0 && {
|
|
4051
|
+
deferredResyncServerIds: uncoveredDeferredServerIds,
|
|
4052
|
+
}),
|
|
4053
|
+
...(auxNoAnswerServerIds.length > 0 && { auxNoAnswerServerIds }),
|
|
4054
|
+
},
|
|
4055
|
+
});
|
|
4056
|
+
}
|
|
2525
4057
|
// #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
|
|
2526
4058
|
// "actually warm" signal `ensureWarmForSweep` waits for — mark every
|
|
2527
4059
|
// spawned server so a later sweep in this session sees the check as a
|
|
@@ -2533,9 +4065,10 @@ export class LSPService {
|
|
|
2533
4065
|
// timed out are skipped here (rather than gating the whole loop on the
|
|
2534
4066
|
// file-level `inconclusive`).
|
|
2535
4067
|
//
|
|
2536
|
-
// #1470: same per-server reasoning for
|
|
2537
|
-
// ready" means this server answered for this
|
|
2538
|
-
// timer cut off
|
|
4068
|
+
// #1470/#1493: same per-server reasoning for an auxiliary that contributed
|
|
4069
|
+
// no evidence. "Demonstrated ready" means this server answered for this
|
|
4070
|
+
// file; an auxiliary our grace timer cut off, or one that stayed silent
|
|
4071
|
+
// through its own budget, demonstrably did not.
|
|
2539
4072
|
//
|
|
2540
4073
|
// NO TEST PINS THIS LINE, and that is a property of today's readers rather
|
|
2541
4074
|
// than a coverage gap: `ensureWarmForSweep` filters `role === "auxiliary"`
|
|
@@ -2545,13 +4078,23 @@ export class LSPService {
|
|
|
2545
4078
|
// because the mark's meaning is "this server answered", and the moment any
|
|
2546
4079
|
// reader stops filtering auxiliaries out, marking a cut-off scanner warm
|
|
2547
4080
|
// would let it skip a warm-up it never earned.
|
|
4081
|
+
//
|
|
4082
|
+
// #1549: `diagnosticsTimedOut` is primary-attributed now, so this loop can be
|
|
4083
|
+
// reached with an auxiliary that never answered — including on a NON-COLLECTING
|
|
4084
|
+
// touch, which derives no aux wait-outcome rows. `unconfirmedServerIds` covers
|
|
4085
|
+
// that case because `auxNoAnswerServerIds` is computed for every touch, not only
|
|
4086
|
+
// a collecting one; an unheard scanner is therefore already excluded here and
|
|
4087
|
+
// needs no separate guard. A primary cannot reach this loop unheard at all: it
|
|
4088
|
+
// would have set `diagnosticsTimedOut`, and the gates that clear that flag
|
|
4089
|
+
// retract its attribution precisely because they certified its silence AS the
|
|
4090
|
+
// answer (`retractPrimaryTimeoutAttribution`).
|
|
2548
4091
|
const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
|
|
2549
|
-
const
|
|
4092
|
+
const uncoveredServerIds = new Set(unconfirmedServerIds);
|
|
2550
4093
|
if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
|
|
2551
4094
|
for (const entry of spawned) {
|
|
2552
4095
|
if (notifyTimedOutServerIds.has(entry.info.id))
|
|
2553
4096
|
continue;
|
|
2554
|
-
if (
|
|
4097
|
+
if (uncoveredServerIds.has(entry.info.id))
|
|
2555
4098
|
continue;
|
|
2556
4099
|
const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
2557
4100
|
if (key)
|
|
@@ -2567,8 +4110,8 @@ export class LSPService {
|
|
|
2567
4110
|
// empty `collected` must never erase a previously-confirmed non-empty
|
|
2568
4111
|
// record (that's the #570 bug — a timeout silently reporting as clean
|
|
2569
4112
|
// and wiping out known-good diagnostic state).
|
|
2570
|
-
// #1470: a PARTIAL touch is the same hazard wearing a different flag.
|
|
2571
|
-
// merged array is missing whatever the
|
|
4113
|
+
// #1470/#1493: a PARTIAL touch is the same hazard wearing a different flag.
|
|
4114
|
+
// Its merged array is missing whatever the unreporting auxiliary would have said, so
|
|
2572
4115
|
// priming the cache with it would let `actionable-warnings`' hash-guarded
|
|
2573
4116
|
// read replay a partially-covered result as an authoritative observation —
|
|
2574
4117
|
// and an empty one would DELETE a previously-confirmed record on the strength
|
|
@@ -2597,12 +4140,23 @@ export class LSPService {
|
|
|
2597
4140
|
const result = { diags: collected ?? [] };
|
|
2598
4141
|
if (collected !== undefined && inconclusive) {
|
|
2599
4142
|
result.inconclusive = true;
|
|
4143
|
+
// #1549: name the primary that produced the verdict and which deadline it
|
|
4144
|
+
// missed, so a forensic sweep reads the cause instead of inferring it from
|
|
4145
|
+
// duration histograms. Absent ids on an inconclusive touch mean the
|
|
4146
|
+
// attribution was not derivable (a client with no per-path publication
|
|
4147
|
+
// stamp) — honest, and the same fail-closed verdict as before.
|
|
4148
|
+
if (verdict.inconclusiveServerIds) {
|
|
4149
|
+
result.inconclusiveServerIds = verdict.inconclusiveServerIds;
|
|
4150
|
+
}
|
|
4151
|
+
result.inconclusiveReason = verdict.inconclusiveReason;
|
|
2600
4152
|
}
|
|
2601
4153
|
else if (collected !== undefined && coverageGap) {
|
|
2602
|
-
// #1470: narrowed, not collapsed.
|
|
2603
|
-
//
|
|
2604
|
-
//
|
|
2605
|
-
//
|
|
4154
|
+
// #1470/#1493: narrowed, not collapsed. Reached for EITHER no-answer
|
|
4155
|
+
// shape — a cut-off auxiliary or a silent one with nothing published for
|
|
4156
|
+
// this content. The primary's findings ride along in `.diags` exactly as
|
|
4157
|
+
// before; what changes is that the touch now states which servers it does
|
|
4158
|
+
// not speak for, so no consumer can read this as a full clean bill of
|
|
4159
|
+
// health.
|
|
2606
4160
|
result.confirmation = "partial";
|
|
2607
4161
|
result.unconfirmedServerIds = [...unconfirmedServerIds];
|
|
2608
4162
|
}
|
|
@@ -2630,7 +4184,17 @@ export class LSPService {
|
|
|
2630
4184
|
// partially-mocked client) yields "unknown" rather than throwing —
|
|
2631
4185
|
// unknown preserves pre-#1095 behavior for that contributor.
|
|
2632
4186
|
[
|
|
2633
|
-
|
|
4187
|
+
// #1459/#1549: a contributor whose findings the merge DROPPED must
|
|
4188
|
+
// not decide the merged verdict either — its binding describes
|
|
4189
|
+
// bytes this result no longer contains.
|
|
4190
|
+
// #1586: read off the same frozen set the drop used. Filtering the
|
|
4191
|
+
// raw deferral set instead excluded the fingerprint of a
|
|
4192
|
+
// deferred-but-COVERED scanner whose findings the merge had just
|
|
4193
|
+
// kept, which diverges whenever the primary is version-less and
|
|
4194
|
+
// that scanner is the only contributor with a fingerprint.
|
|
4195
|
+
...spawned
|
|
4196
|
+
.filter((entry) => !droppedAuxiliaryServerIds.has(entry.info.id))
|
|
4197
|
+
.map((entry) => entry.client.getDiagnosticBinding?.(filePath)),
|
|
2634
4198
|
...carriedAuxiliary.map((entry) => entry.binding),
|
|
2635
4199
|
]);
|
|
2636
4200
|
result.binding = binding;
|
|
@@ -2671,6 +4235,16 @@ export class LSPService {
|
|
|
2671
4235
|
}),
|
|
2672
4236
|
diagnosticsTimedOut,
|
|
2673
4237
|
inconclusive,
|
|
4238
|
+
// #1549: the attribution the issue's observability contract asks for —
|
|
4239
|
+
// WHICH primary made the touch inconclusive and which deadline it missed
|
|
4240
|
+
// (`notify-write` vs `diagnostics-wait`, or `mixed`). Absent on a
|
|
4241
|
+
// conclusive touch, which blames nobody.
|
|
4242
|
+
...(verdict.inconclusiveServerIds && {
|
|
4243
|
+
inconclusiveServerIds: verdict.inconclusiveServerIds,
|
|
4244
|
+
}),
|
|
4245
|
+
...(verdict.inconclusiveReason && {
|
|
4246
|
+
inconclusiveReason: verdict.inconclusiveReason,
|
|
4247
|
+
}),
|
|
2674
4248
|
// #1470: the touch's own honesty verdict, so a `cut_off` row in
|
|
2675
4249
|
// `lsp_aux_wait_outcome` can be joined to the touch that produced it and
|
|
2676
4250
|
// shown NOT to have claimed confirmation for that server's coverage.
|
|
@@ -2683,6 +4257,32 @@ export class LSPService {
|
|
|
2683
4257
|
// Absent when no aux was cut off. These servers' diagnostics are
|
|
2684
4258
|
// advisory-only and will surface on the next edit from their cache.
|
|
2685
4259
|
...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
|
|
4260
|
+
// #1493: the full set the confirmation was narrowed on — the cut-off
|
|
4261
|
+
// ids plus every auxiliary that stayed silent with nothing published
|
|
4262
|
+
// for this content. Absent when the touch speaks for every server.
|
|
4263
|
+
// This is the join key for the issue's observability contract: a
|
|
4264
|
+
// `silent` row in `lsp_aux_wait_outcome` must appear here, on a touch
|
|
4265
|
+
// whose `confirmation` is `"partial"`.
|
|
4266
|
+
...(auxUnconfirmedServerIds !== undefined && {
|
|
4267
|
+
auxUnconfirmedServerIds,
|
|
4268
|
+
}),
|
|
4269
|
+
// #1459: scanners this touch does not speak for because their breaker
|
|
4270
|
+
// was open, or because the resync gate deferred their write. Separate
|
|
4271
|
+
// fields because these two doors open BEFORE any wait, so neither can
|
|
4272
|
+
// appear in an `lsp_aux_wait_outcome` row on the sweep path. Absent
|
|
4273
|
+
// when every configured scanner got this content — #1586 included: a
|
|
4274
|
+
// deferred scanner already bound to these bytes is covered, so it is not
|
|
4275
|
+
// named here either. The gate's own action is recorded regardless, in
|
|
4276
|
+
// `lsp_notify_resync_deferred`.
|
|
4277
|
+
...(brokenSkippedServerIds.length > 0 && { brokenSkippedServerIds }),
|
|
4278
|
+
...(uncoveredDeferredServerIds.length > 0 && {
|
|
4279
|
+
deferredResyncServerIds: uncoveredDeferredServerIds,
|
|
4280
|
+
}),
|
|
4281
|
+
// #1549: auxiliaries whose own deadline lapsed — the wait produced no
|
|
4282
|
+
// publication, or the notify write never landed. Distinct from the fields
|
|
4283
|
+
// above (a different door) and from `auxUnconfirmedServerIds` (derived
|
|
4284
|
+
// from aux wait-outcome rows, which a non-collecting touch never emits).
|
|
4285
|
+
...(auxNoAnswerServerIds.length > 0 && { auxNoAnswerServerIds }),
|
|
2686
4286
|
},
|
|
2687
4287
|
});
|
|
2688
4288
|
return result;
|
|
@@ -2918,8 +4518,15 @@ export class LSPService {
|
|
|
2918
4518
|
this.lastKnownContentHash.delete(normalizedPath);
|
|
2919
4519
|
return merged;
|
|
2920
4520
|
}
|
|
4521
|
+
/**
|
|
4522
|
+
* Delegates to {@link hashDiagnosticContent} rather than re-hashing here. Every
|
|
4523
|
+
* comparison this hash takes part in (`publishedThisContent`, the carried-aux
|
|
4524
|
+
* check, the last-known content guard) is against a hash the client produced
|
|
4525
|
+
* with that function, so the two implementations must agree byte for byte —
|
|
4526
|
+
* a duplicate is a silent divergence waiting for one of them to be tuned.
|
|
4527
|
+
*/
|
|
2921
4528
|
hashContent(content) {
|
|
2922
|
-
return
|
|
4529
|
+
return hashDiagnosticContent(content);
|
|
2923
4530
|
}
|
|
2924
4531
|
/**
|
|
2925
4532
|
* #1095: compose the content `binding` for a merged diagnostics result across
|
|
@@ -2962,6 +4569,9 @@ export class LSPService {
|
|
|
2962
4569
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
2963
4570
|
if (!spawned)
|
|
2964
4571
|
return [];
|
|
4572
|
+
if (!spawned.client.getOperationSupport().definition) {
|
|
4573
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for definition");
|
|
4574
|
+
}
|
|
2965
4575
|
return spawned.client.definition(filePath, line, character);
|
|
2966
4576
|
}
|
|
2967
4577
|
/**
|
|
@@ -2971,6 +4581,9 @@ export class LSPService {
|
|
|
2971
4581
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
2972
4582
|
if (!spawned)
|
|
2973
4583
|
return [];
|
|
4584
|
+
if (!spawned.client.getOperationSupport().typeDefinition) {
|
|
4585
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for typeDefinition");
|
|
4586
|
+
}
|
|
2974
4587
|
return spawned.client.typeDefinition(filePath, line, character);
|
|
2975
4588
|
}
|
|
2976
4589
|
/**
|
|
@@ -2980,6 +4593,9 @@ export class LSPService {
|
|
|
2980
4593
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
2981
4594
|
if (!spawned)
|
|
2982
4595
|
return [];
|
|
4596
|
+
if (!spawned.client.getOperationSupport().declaration) {
|
|
4597
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for declaration");
|
|
4598
|
+
}
|
|
2983
4599
|
return spawned.client.declaration(filePath, line, character);
|
|
2984
4600
|
}
|
|
2985
4601
|
/**
|
|
@@ -2989,6 +4605,9 @@ export class LSPService {
|
|
|
2989
4605
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
2990
4606
|
if (!spawned)
|
|
2991
4607
|
return [];
|
|
4608
|
+
if (!spawned.client.getOperationSupport().references) {
|
|
4609
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for references");
|
|
4610
|
+
}
|
|
2992
4611
|
return spawned.client.references(filePath, line, character, includeDeclaration);
|
|
2993
4612
|
}
|
|
2994
4613
|
/**
|
|
@@ -2998,6 +4617,9 @@ export class LSPService {
|
|
|
2998
4617
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
2999
4618
|
if (!spawned)
|
|
3000
4619
|
return null;
|
|
4620
|
+
if (!spawned.client.getOperationSupport().hover) {
|
|
4621
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for hover");
|
|
4622
|
+
}
|
|
3001
4623
|
return spawned.client.hover(filePath, line, character);
|
|
3002
4624
|
}
|
|
3003
4625
|
/**
|
|
@@ -3007,6 +4629,9 @@ export class LSPService {
|
|
|
3007
4629
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3008
4630
|
if (!spawned)
|
|
3009
4631
|
return null;
|
|
4632
|
+
if (!spawned.client.getOperationSupport().signatureHelp) {
|
|
4633
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for signatureHelp");
|
|
4634
|
+
}
|
|
3010
4635
|
return spawned.client.signatureHelp(filePath, line, character);
|
|
3011
4636
|
}
|
|
3012
4637
|
/**
|
|
@@ -3016,44 +4641,116 @@ export class LSPService {
|
|
|
3016
4641
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3017
4642
|
if (!spawned)
|
|
3018
4643
|
return [];
|
|
4644
|
+
if (!spawned.client.getOperationSupport().documentSymbol) {
|
|
4645
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for documentSymbol");
|
|
4646
|
+
}
|
|
3019
4647
|
return spawned.client.documentSymbol(filePath);
|
|
3020
4648
|
}
|
|
4649
|
+
/**
|
|
4650
|
+
* Resolves "the target client" for a workspace-scope query that has no
|
|
4651
|
+
* filePath to route through `getClientForFile`. `state.clients` is keyed
|
|
4652
|
+
* `${serverId}:${root}` in spawn order, not role order, so an auxiliary
|
|
4653
|
+
* scanner (ast-grep, opengrep, zizmor, ...) that happens to spawn first in
|
|
4654
|
+
* a polyglot workspace used to win every no-filePath query outright (#1812
|
|
4655
|
+
* — a supporting primary server spawned later never got a look-in). Scans
|
|
4656
|
+
* for the first LIVE client matching `predicate` (when given), preferring
|
|
4657
|
+
* any primary (non-`"auxiliary"` role) match over an auxiliary one — the
|
|
4658
|
+
* same primary-over-auxiliary preference `getClientForFile` encodes via
|
|
4659
|
+
* its `role !== "auxiliary"` filter (this file, `getClientForFile`) and
|
|
4660
|
+
* `getAliveServerIds` groups by. `isAlive()` is required for BOTH the
|
|
4661
|
+
* preferred and fallback candidate — mirrors `getCapabilitySnapshots`'s
|
|
4662
|
+
* own no-filePath branch (this file, ~line 5868), whose liveness filter
|
|
4663
|
+
* this helper otherwise duplicates; without it a dead primary would win
|
|
4664
|
+
* over a live, answering auxiliary. Role is read from the map key's
|
|
4665
|
+
* `serverId` prefix against `LSP_SERVERS`, the same single source of
|
|
4666
|
+
* truth `getCapabilitySnapshots` already parses that key from — never a
|
|
4667
|
+
* second, hand-rolled role table. A `serverId` prefix absent from
|
|
4668
|
+
* `LSP_SERVERS` (should not happen in practice — every spawned client's
|
|
4669
|
+
* key is built from a known server's `id`) resolves to `role === undefined`,
|
|
4670
|
+
* which falls through to the primary branch: unknown treated as primary,
|
|
4671
|
+
* never silently dropped. Returns undefined only when NO live client
|
|
4672
|
+
* (primary or auxiliary) matches.
|
|
4673
|
+
*/
|
|
4674
|
+
selectWorkspaceScopeClient(predicate) {
|
|
4675
|
+
let auxFallback;
|
|
4676
|
+
for (const [key, client] of this.state.clients) {
|
|
4677
|
+
if (!client.isAlive())
|
|
4678
|
+
continue;
|
|
4679
|
+
if (predicate && !predicate(client))
|
|
4680
|
+
continue;
|
|
4681
|
+
const separator = key.indexOf(":");
|
|
4682
|
+
const serverId = separator >= 0 ? key.slice(0, separator) : key;
|
|
4683
|
+
const role = LSP_SERVERS.find((s) => s.id === serverId)?.role;
|
|
4684
|
+
if (role === "auxiliary") {
|
|
4685
|
+
if (!auxFallback)
|
|
4686
|
+
auxFallback = { client, serverId };
|
|
4687
|
+
continue;
|
|
4688
|
+
}
|
|
4689
|
+
return { client, serverId };
|
|
4690
|
+
}
|
|
4691
|
+
return auxFallback;
|
|
4692
|
+
}
|
|
3021
4693
|
/**
|
|
3022
4694
|
* Navigation: workspace-wide symbol search
|
|
4695
|
+
*
|
|
4696
|
+
* #1789: gated on the target server's advertised `workspaceSymbolProvider`
|
|
4697
|
+
* (the same `getOperationSupport().workspaceSymbol` single source of truth
|
|
4698
|
+
* `lsp-document-symbols.ts`'s documentSymbol gate reads — see clients/
|
|
4699
|
+
* lsp-document-symbols.ts:50).
|
|
4700
|
+
*
|
|
4701
|
+
* #1812: without a path, the no-filePath branch used to stop at
|
|
4702
|
+
* `state.clients`' first entry by insertion order regardless of whether it
|
|
4703
|
+
* supported `workspace/symbol` — an auxiliary spawned first silently ate
|
|
4704
|
+
* every query with `[]` and zero requests, even when a supporting primary
|
|
4705
|
+
* server was already spawned too. `selectWorkspaceScopeClient` now scans
|
|
4706
|
+
* for the first client that DOES support it, preferring a primary over an
|
|
4707
|
+
* auxiliary; only when none of the spawned clients support it does this
|
|
4708
|
+
* fall back to `[]`.
|
|
3023
4709
|
*/
|
|
3024
|
-
async workspaceSymbol(query, filePath) {
|
|
4710
|
+
async workspaceSymbol(query, filePath, attribution) {
|
|
3025
4711
|
if (filePath) {
|
|
3026
4712
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3027
4713
|
if (!spawned)
|
|
3028
4714
|
return [];
|
|
4715
|
+
if (!spawned.client.getOperationSupport().workspaceSymbol)
|
|
4716
|
+
return [];
|
|
3029
4717
|
return spawned.client.workspaceSymbol(query);
|
|
3030
4718
|
}
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
if (clients.length === 0)
|
|
4719
|
+
const target = this.selectWorkspaceScopeClient((client) => client.getOperationSupport().workspaceSymbol);
|
|
4720
|
+
if (!target)
|
|
3034
4721
|
return [];
|
|
3035
|
-
|
|
4722
|
+
if (attribution)
|
|
4723
|
+
attribution.workspaceSymbol = target.serverId;
|
|
4724
|
+
return target.client.workspaceSymbol(query);
|
|
3036
4725
|
}
|
|
3037
4726
|
/**
|
|
3038
4727
|
* Commands advertised for workspace/executeCommand. If filePath is given,
|
|
3039
|
-
* the server for that file; otherwise the first active client
|
|
4728
|
+
* the server for that file; otherwise the first active client, preferring
|
|
4729
|
+
* a primary over an auxiliary scanner spawned first (#1812 sweep — see
|
|
4730
|
+
* `selectWorkspaceScopeClient`).
|
|
3040
4731
|
*/
|
|
3041
|
-
async getAdvertisedCommands(filePath) {
|
|
4732
|
+
async getAdvertisedCommands(filePath, attribution) {
|
|
3042
4733
|
if (filePath) {
|
|
3043
4734
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3044
4735
|
if (!spawned)
|
|
3045
4736
|
return [];
|
|
3046
4737
|
return spawned.client.getAdvertisedCommands();
|
|
3047
4738
|
}
|
|
3048
|
-
const first = this.
|
|
3049
|
-
|
|
4739
|
+
const first = this.selectWorkspaceScopeClient();
|
|
4740
|
+
if (!first)
|
|
4741
|
+
return [];
|
|
4742
|
+
if (attribution)
|
|
4743
|
+
attribution.getAdvertisedCommands = first.serverId;
|
|
4744
|
+
return first.client.getAdvertisedCommands();
|
|
3050
4745
|
}
|
|
3051
4746
|
/**
|
|
3052
4747
|
* Run a server command via workspace/executeCommand (hardened: allowlisted by
|
|
3053
4748
|
* advertisement in the client). If filePath is given, target that file's
|
|
3054
|
-
* server; otherwise the first active client
|
|
4749
|
+
* server; otherwise the first active client, preferring a primary over an
|
|
4750
|
+
* auxiliary scanner spawned first (#1812 sweep — see
|
|
4751
|
+
* `selectWorkspaceScopeClient`).
|
|
3055
4752
|
*/
|
|
3056
|
-
async executeCommand(filePath, command, args, mutationContext) {
|
|
4753
|
+
async executeCommand(filePath, command, args, mutationContext, attribution) {
|
|
3057
4754
|
if (filePath) {
|
|
3058
4755
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3059
4756
|
if (!spawned) {
|
|
@@ -3061,16 +4758,74 @@ export class LSPService {
|
|
|
3061
4758
|
}
|
|
3062
4759
|
return spawned.client.executeCommand(command, args, mutationContext);
|
|
3063
4760
|
}
|
|
3064
|
-
const first = this.
|
|
4761
|
+
const first = this.selectWorkspaceScopeClient();
|
|
3065
4762
|
if (!first)
|
|
3066
4763
|
return { executed: false, reason: "no active LSP server" };
|
|
3067
|
-
|
|
4764
|
+
if (attribution)
|
|
4765
|
+
attribution.executeCommand = first.serverId;
|
|
4766
|
+
return first.client.executeCommand(command, args, mutationContext);
|
|
4767
|
+
}
|
|
4768
|
+
/**
|
|
4769
|
+
* #1640: run a read-only probe command against a server that is ALREADY
|
|
4770
|
+
* running for this file. Two hard guarantees the mutation-channel
|
|
4771
|
+
* `executeCommand` above cannot give a render-path probe:
|
|
4772
|
+
*
|
|
4773
|
+
* - **Never spawns.** It resolves the already-connected client map the way
|
|
4774
|
+
* `isServerAliveForFile` does, instead of routing through
|
|
4775
|
+
* `getClientForFile` → `ensureClientForServer`. A probe that spawns a
|
|
4776
|
+
* language-server fleet to answer a question about how to RENDER a
|
|
4777
|
+
* diagnostic is a cost the caller never asked for — and under warm attach
|
|
4778
|
+
* the freshly spawned server's answer would not even be the warm session's
|
|
4779
|
+
* answer.
|
|
4780
|
+
* - **Never opens the mutation window.** `executeReadOnlyCommand` leaves
|
|
4781
|
+
* `serverEditsAllowed` and `activeMutationContext` alone, so an in-flight
|
|
4782
|
+
* real command's mutation context survives a concurrent probe.
|
|
4783
|
+
*
|
|
4784
|
+
* Returns `{executed:false}` — never a thrown error and never a spawn — when
|
|
4785
|
+
* no live client owns the file. Callers must read that as UNKNOWN.
|
|
4786
|
+
*/
|
|
4787
|
+
async executeReadOnlyCommandOnLiveClient(filePath, command, args) {
|
|
4788
|
+
if (this.checkDestroyed()) {
|
|
4789
|
+
return { executed: false, reason: "lsp service destroyed" };
|
|
4790
|
+
}
|
|
4791
|
+
for (const server of getServersForFileWithConfig(filePath)) {
|
|
4792
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
4793
|
+
if (!root)
|
|
4794
|
+
continue;
|
|
4795
|
+
const entry = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
|
|
4796
|
+
if (!entry?.isAlive())
|
|
4797
|
+
continue;
|
|
4798
|
+
const run = entry.executeReadOnlyCommand;
|
|
4799
|
+
if (typeof run !== "function")
|
|
4800
|
+
continue;
|
|
4801
|
+
return run.call(entry, command, args);
|
|
4802
|
+
}
|
|
4803
|
+
return { executed: false, reason: "no live LSP server for file" };
|
|
3068
4804
|
}
|
|
3069
4805
|
/**
|
|
3070
4806
|
* Capability snapshot for LSP operations.
|
|
3071
|
-
* If filePath is provided, probes that server
|
|
4807
|
+
* If filePath is provided, probes that server. Without a filePath the
|
|
4808
|
+
* snapshot describes the whole workspace, so each capability is ORed
|
|
4809
|
+
* across every client `selectWorkspaceScopeClient` would consider.
|
|
4810
|
+
*
|
|
4811
|
+
* #1846: the no-filePath branch used to report ONE client's capabilities,
|
|
4812
|
+
* whichever `selectWorkspaceScopeClient()` returned with no predicate. In
|
|
4813
|
+
* a multi-primary workspace (say `json` spawned before `typescript`), a
|
|
4814
|
+
* first client that does not advertise `workspaceSymbolProvider` reported
|
|
4815
|
+
* the operation unsupported even though a later client advertises it. The
|
|
4816
|
+
* tool layer then refused the call before `workspaceSymbol()` — which
|
|
4817
|
+
* #1812 taught to find the supporting client — was ever reached
|
|
4818
|
+
* (tools/lsp-navigation.ts, the `runWorkspaceSymbolOperation` gate).
|
|
4819
|
+
*
|
|
4820
|
+
* Each capability resolves through `selectWorkspaceScopeClient` with a
|
|
4821
|
+
* per-capability predicate, so this answer is built from the SAME liveness
|
|
4822
|
+
* and primary-over-auxiliary rules that route the operation itself (see
|
|
4823
|
+
* `selectWorkspaceScopeClient`, this file). A dead client therefore cannot
|
|
4824
|
+
* contribute a capability nobody can execute. Capabilities the base client
|
|
4825
|
+
* already reports true are skipped, so the extra scans only run for the
|
|
4826
|
+
* capabilities it lacks.
|
|
3072
4827
|
*/
|
|
3073
|
-
async getOperationSupport(filePath) {
|
|
4828
|
+
async getOperationSupport(filePath, attribution) {
|
|
3074
4829
|
if (filePath) {
|
|
3075
4830
|
const spawned = await this.getClientForFile(filePath);
|
|
3076
4831
|
if (!spawned)
|
|
@@ -3080,19 +4835,36 @@ export class LSPService {
|
|
|
3080
4835
|
return null;
|
|
3081
4836
|
return getter();
|
|
3082
4837
|
}
|
|
3083
|
-
const
|
|
4838
|
+
const readable = (client) => typeof client.getOperationSupport === "function";
|
|
4839
|
+
const first = this.selectWorkspaceScopeClient(readable);
|
|
3084
4840
|
if (!first)
|
|
3085
4841
|
return null;
|
|
3086
|
-
const
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
4842
|
+
const aggregate = { ...first.client.getOperationSupport() };
|
|
4843
|
+
const contributors = {};
|
|
4844
|
+
for (const capability of Object.keys(aggregate)) {
|
|
4845
|
+
if (aggregate[capability]) {
|
|
4846
|
+
contributors[capability] = first.serverId;
|
|
4847
|
+
continue;
|
|
4848
|
+
}
|
|
4849
|
+
const supporter = this.selectWorkspaceScopeClient((client) => readable(client) && Boolean(client.getOperationSupport()[capability]));
|
|
4850
|
+
if (supporter) {
|
|
4851
|
+
aggregate[capability] = true;
|
|
4852
|
+
contributors[capability] = supporter.serverId;
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
4855
|
+
if (attribution) {
|
|
4856
|
+
attribution.getOperationSupport = {
|
|
4857
|
+
baseClientId: first.serverId,
|
|
4858
|
+
contributors,
|
|
4859
|
+
};
|
|
4860
|
+
}
|
|
4861
|
+
return aggregate;
|
|
3090
4862
|
}
|
|
3091
4863
|
/**
|
|
3092
4864
|
* Capability snapshot for workspace diagnostics support.
|
|
3093
4865
|
* If filePath is provided, probes that server; otherwise uses first active client.
|
|
3094
4866
|
*/
|
|
3095
|
-
async getCapabilitySnapshots(filePath) {
|
|
4867
|
+
async getCapabilitySnapshots(filePath, attribution) {
|
|
3096
4868
|
if (this.checkDestroyed())
|
|
3097
4869
|
return [];
|
|
3098
4870
|
const snapshots = [];
|
|
@@ -3132,9 +4904,17 @@ export class LSPService {
|
|
|
3132
4904
|
launchVariant: client.getLaunchVariant?.(),
|
|
3133
4905
|
});
|
|
3134
4906
|
}
|
|
4907
|
+
if (attribution) {
|
|
4908
|
+
attribution.getCapabilitySnapshots = {
|
|
4909
|
+
clientIds: snapshots
|
|
4910
|
+
.slice(0, WORKSPACE_ATTRIBUTION_CLIENT_CAP)
|
|
4911
|
+
.map((snapshot) => snapshot.serverId),
|
|
4912
|
+
clientCount: snapshots.length,
|
|
4913
|
+
};
|
|
4914
|
+
}
|
|
3135
4915
|
return snapshots;
|
|
3136
4916
|
}
|
|
3137
|
-
async getWorkspaceDiagnosticsSupport(filePath) {
|
|
4917
|
+
async getWorkspaceDiagnosticsSupport(filePath, attribution) {
|
|
3138
4918
|
if (filePath) {
|
|
3139
4919
|
const spawned = await this.getClientForFile(filePath);
|
|
3140
4920
|
if (!spawned)
|
|
@@ -3144,13 +4924,15 @@ export class LSPService {
|
|
|
3144
4924
|
return null;
|
|
3145
4925
|
return getter();
|
|
3146
4926
|
}
|
|
3147
|
-
const first = this.
|
|
4927
|
+
const first = this.selectWorkspaceScopeClient();
|
|
3148
4928
|
if (!first)
|
|
3149
4929
|
return null;
|
|
3150
|
-
const getter = first.getWorkspaceDiagnosticsSupport;
|
|
4930
|
+
const getter = first.client.getWorkspaceDiagnosticsSupport;
|
|
3151
4931
|
if (typeof getter !== "function")
|
|
3152
4932
|
return null;
|
|
3153
|
-
|
|
4933
|
+
if (attribution)
|
|
4934
|
+
attribution.getWorkspaceDiagnosticsSupport = first.serverId;
|
|
4935
|
+
return getter.call(first.client);
|
|
3154
4936
|
}
|
|
3155
4937
|
/**
|
|
3156
4938
|
* Navigation: available code actions at position/range
|
|
@@ -3159,6 +4941,9 @@ export class LSPService {
|
|
|
3159
4941
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3160
4942
|
if (!spawned)
|
|
3161
4943
|
return [];
|
|
4944
|
+
if (!spawned.client.getOperationSupport().codeAction) {
|
|
4945
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for codeAction");
|
|
4946
|
+
}
|
|
3162
4947
|
return spawned.client.codeAction(filePath, line, character, endLine, endCharacter);
|
|
3163
4948
|
}
|
|
3164
4949
|
/**
|
|
@@ -3168,6 +4953,9 @@ export class LSPService {
|
|
|
3168
4953
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3169
4954
|
if (!spawned)
|
|
3170
4955
|
return null;
|
|
4956
|
+
if (!spawned.client.getOperationSupport().rename) {
|
|
4957
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for rename");
|
|
4958
|
+
}
|
|
3171
4959
|
return spawned.client.rename(filePath, line, character, newName);
|
|
3172
4960
|
}
|
|
3173
4961
|
async renameFile(oldFilePath, newFilePath, options) {
|
|
@@ -3252,16 +5040,16 @@ export class LSPService {
|
|
|
3252
5040
|
}));
|
|
3253
5041
|
const closeFailures = [];
|
|
3254
5042
|
await Promise.all(openDocuments.map(async ({ serverId, client }) => {
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
5043
|
+
// #1621: bounded so one wedged server's didClose write cannot stall
|
|
5044
|
+
// this Promise.all — and therefore the whole rename — for every
|
|
5045
|
+
// other client alongside it.
|
|
5046
|
+
const result = await runRenameNotify(() => client.closeDocument(oldFilePath), RENAME_NOTIFY_TIMEOUT_MS);
|
|
5047
|
+
if (!result.ok) {
|
|
3260
5048
|
closeFailures.push({
|
|
3261
5049
|
serverId,
|
|
3262
|
-
error:
|
|
5050
|
+
error: result.error,
|
|
5051
|
+
disposition: result.disposition,
|
|
3263
5052
|
});
|
|
3264
|
-
return undefined;
|
|
3265
5053
|
}
|
|
3266
5054
|
}));
|
|
3267
5055
|
if (closeFailures.length > 0) {
|
|
@@ -3280,8 +5068,49 @@ export class LSPService {
|
|
|
3280
5068
|
// next genuine open (#1147 P3-7).
|
|
3281
5069
|
const content = await fs.readFile(oldFilePath, "utf-8");
|
|
3282
5070
|
const languageId = getLanguageId(oldFilePath) ?? "plaintext";
|
|
3283
|
-
|
|
3284
|
-
|
|
5071
|
+
const resyncFailures = [];
|
|
5072
|
+
// #1621 F1: the resync write is the SAME class of notify as the didClose
|
|
5073
|
+
// it is repairing after — a pipe that is wedged for didClose is wedged
|
|
5074
|
+
// for every subsequent write on it too, so this bare await reintroduced
|
|
5075
|
+
// the exact unbounded primitive one Promise.all up. Bound it with the
|
|
5076
|
+
// same budget and record the disposition rather than let a wedged
|
|
5077
|
+
// resync silently move the hang here instead of removing it.
|
|
5078
|
+
await Promise.all(openDocuments.map(async ({ serverId, client }) => {
|
|
5079
|
+
const resyncResult = await runRenameNotify(() => client.notify.open(oldFilePath, content, languageId, true, true), RENAME_NOTIFY_TIMEOUT_MS);
|
|
5080
|
+
if (!resyncResult.ok) {
|
|
5081
|
+
resyncFailures.push({
|
|
5082
|
+
serverId,
|
|
5083
|
+
error: resyncResult.error,
|
|
5084
|
+
disposition: resyncResult.disposition,
|
|
5085
|
+
});
|
|
5086
|
+
}
|
|
5087
|
+
}));
|
|
5088
|
+
if (resyncFailures.length > 0) {
|
|
5089
|
+
// A resync failure is not swallowed: the affected client is left with
|
|
5090
|
+
// no open document at all until its next genuine open, so this is
|
|
5091
|
+
// logged for the same reason lsp_client_shutdown records a forced
|
|
5092
|
+
// teardown — a degraded resync must be countable from the log.
|
|
5093
|
+
logLatency({
|
|
5094
|
+
type: "phase",
|
|
5095
|
+
phase: "lsp_rename_resync_failed",
|
|
5096
|
+
filePath: oldFilePath,
|
|
5097
|
+
durationMs: 0,
|
|
5098
|
+
metadata: {
|
|
5099
|
+
failures: resyncFailures.map((failure) => ({
|
|
5100
|
+
serverId: failure.serverId,
|
|
5101
|
+
disposition: failure.disposition,
|
|
5102
|
+
error: failure.error,
|
|
5103
|
+
})),
|
|
5104
|
+
},
|
|
5105
|
+
});
|
|
5106
|
+
}
|
|
5107
|
+
const closeFailureSummary = closeFailures
|
|
5108
|
+
.map((failure) => `${failure.serverId} (${failure.disposition}): ${failure.error}`)
|
|
5109
|
+
.join("; ");
|
|
5110
|
+
const resyncFailureSummary = resyncFailures.length > 0
|
|
5111
|
+
? ` (resync also failed: ${resyncFailures.map((failure) => `${failure.serverId} (${failure.disposition}): ${failure.error}`).join("; ")})`
|
|
5112
|
+
: "";
|
|
5113
|
+
throw new Error(`workspace/didClose failed; rename aborted: ${closeFailureSummary}${resyncFailureSummary}`);
|
|
3285
5114
|
}
|
|
3286
5115
|
let renameApplied;
|
|
3287
5116
|
try {
|
|
@@ -3314,21 +5143,19 @@ export class LSPService {
|
|
|
3314
5143
|
const renameDescription = `Renamed ${relOld} → ${relNew}`;
|
|
3315
5144
|
await Promise.all(activeClients.map(async ({ serverId, client }) => {
|
|
3316
5145
|
const opened = openDocuments.find((entry) => entry.serverId === serverId);
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
}
|
|
3326
|
-
catch (err) {
|
|
5146
|
+
// #1621: bounded for the same reason as the didClose notify above —
|
|
5147
|
+
// rename propagation is best-effort advice to servers, not a
|
|
5148
|
+
// correctness gate, so a wedged client's notify must not stall the
|
|
5149
|
+
// healthy clients settling alongside it in this Promise.all.
|
|
5150
|
+
const result = await runRenameNotify(() => opened?.oldUri
|
|
5151
|
+
? client.didRenameFiles(oldFilePath, newFilePath, opened.oldUri, destinationUriPreservingSpelling(opened.oldUri, oldFilePath, newFilePath))
|
|
5152
|
+
: client.didRenameFiles(oldFilePath, newFilePath), RENAME_NOTIFY_TIMEOUT_MS);
|
|
5153
|
+
if (!result.ok) {
|
|
3327
5154
|
didRenameFailures.push({
|
|
3328
5155
|
serverId,
|
|
3329
|
-
error:
|
|
5156
|
+
error: result.error,
|
|
5157
|
+
disposition: result.disposition,
|
|
3330
5158
|
});
|
|
3331
|
-
return undefined;
|
|
3332
5159
|
}
|
|
3333
5160
|
}));
|
|
3334
5161
|
const files = [...new Set([...applied.files, oldFilePath, newFilePath])];
|
|
@@ -3378,6 +5205,9 @@ export class LSPService {
|
|
|
3378
5205
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3379
5206
|
if (!spawned)
|
|
3380
5207
|
return [];
|
|
5208
|
+
if (!spawned.client.getOperationSupport().implementation) {
|
|
5209
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for implementation");
|
|
5210
|
+
}
|
|
3381
5211
|
return spawned.client.implementation(filePath, line, character);
|
|
3382
5212
|
}
|
|
3383
5213
|
/**
|
|
@@ -3387,24 +5217,38 @@ export class LSPService {
|
|
|
3387
5217
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3388
5218
|
if (!spawned)
|
|
3389
5219
|
return [];
|
|
5220
|
+
if (!spawned.client.getOperationSupport().callHierarchy) {
|
|
5221
|
+
throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for prepareCallHierarchy");
|
|
5222
|
+
}
|
|
3390
5223
|
return spawned.client.prepareCallHierarchy(filePath, line, character);
|
|
3391
5224
|
}
|
|
3392
5225
|
/**
|
|
3393
5226
|
* Navigation: find incoming calls (callers)
|
|
5227
|
+
*
|
|
5228
|
+
* #1803: gated on the target server's advertised `callHierarchyProvider`
|
|
5229
|
+
* (the same `getOperationSupport().callHierarchy` single source of truth
|
|
5230
|
+
* populated by `detectOperationSupport` in clients/lsp/client.ts — see
|
|
5231
|
+
* client.ts:5253). Mirrors the #1789 gate on `workspaceSymbol` above.
|
|
3394
5232
|
*/
|
|
3395
5233
|
async incomingCalls(item) {
|
|
3396
5234
|
const spawned = await this.getClientForFile(uriToPath(item.uri), NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3397
5235
|
if (!spawned)
|
|
3398
5236
|
return [];
|
|
5237
|
+
if (!spawned.client.getOperationSupport().callHierarchy)
|
|
5238
|
+
return [];
|
|
3399
5239
|
return spawned.client.incomingCalls(item);
|
|
3400
5240
|
}
|
|
3401
5241
|
/**
|
|
3402
5242
|
* Navigation: find outgoing calls (callees)
|
|
5243
|
+
*
|
|
5244
|
+
* #1803: same gate as `incomingCalls` above.
|
|
3403
5245
|
*/
|
|
3404
5246
|
async outgoingCalls(item) {
|
|
3405
5247
|
const spawned = await this.getClientForFile(uriToPath(item.uri), NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
3406
5248
|
if (!spawned)
|
|
3407
5249
|
return [];
|
|
5250
|
+
if (!spawned.client.getOperationSupport().callHierarchy)
|
|
5251
|
+
return [];
|
|
3408
5252
|
return spawned.client.outgoingCalls(item);
|
|
3409
5253
|
}
|
|
3410
5254
|
/**
|
|
@@ -3468,10 +5312,13 @@ export class LSPService {
|
|
|
3468
5312
|
* attempts never proved it can answer diagnostics. Warm-up stays
|
|
3469
5313
|
* `clientScope:"primary"` (not the sweep's `"all"`) on purpose: `"all"`
|
|
3470
5314
|
* would additionally spawn the sweep-EXCLUDED auxiliaries
|
|
3471
|
-
* (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`),
|
|
3472
|
-
*
|
|
3473
|
-
*
|
|
3474
|
-
* falsely condemning it.
|
|
5315
|
+
* (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), which is work this warm-up does not
|
|
5316
|
+
* need. (Before #1549 it was also unsafe: `touchFile`'s `inconclusive` flag was
|
|
5317
|
+
* touch-wide, so one slow advisory auxiliary suppressed the `demonstratedReady`
|
|
5318
|
+
* marking for a perfectly healthy primary — falsely condemning it. The verdict is
|
|
5319
|
+
* per-server now, and the marking loop skips only the servers that did not answer
|
|
5320
|
+
* for themselves, so the scope choice is a cost argument rather than a
|
|
5321
|
+
* correctness one.) Recording per-primary-server outcomes and skipping
|
|
3475
5322
|
* on those is the correct, non-regressing way to cover the servers the sweep
|
|
3476
5323
|
* actually gates on (the sweep groups by primary server, so the group this
|
|
3477
5324
|
* warms IS the one whose per-file touches would drag).
|
|
@@ -3664,9 +5511,30 @@ export class LSPService {
|
|
|
3664
5511
|
* This is intentionally expensive and used only by explicit project-wide tools.
|
|
3665
5512
|
*/
|
|
3666
5513
|
async runWorkspaceDiagnostics(cwd, options = {}) {
|
|
5514
|
+
// #1618: hold the shared sweep gate for this call's ENTIRE lifetime —
|
|
5515
|
+
// counter-based (not a boolean) so two overlapping `mode=full` calls each
|
|
5516
|
+
// release independently, and try/finally so a throw or an aborted sweep
|
|
5517
|
+
// still releases it (a leaked hold would permanently disable idle reset,
|
|
5518
|
+
// the inverse defect). While held, `clients/runtime-turn.ts`'s idle-reset
|
|
5519
|
+
// timer defers instead of destroying this service mid-sweep.
|
|
5520
|
+
const releaseSweepHold = acquireWorkspaceSweepHold();
|
|
5521
|
+
try {
|
|
5522
|
+
return await this.runWorkspaceDiagnosticsSwept(cwd, options);
|
|
5523
|
+
}
|
|
5524
|
+
finally {
|
|
5525
|
+
releaseSweepHold();
|
|
5526
|
+
}
|
|
5527
|
+
}
|
|
5528
|
+
async runWorkspaceDiagnosticsSwept(cwd, options = {}) {
|
|
3667
5529
|
const startedAt = Date.now();
|
|
3668
5530
|
const root = path.resolve(cwd);
|
|
3669
5531
|
const { signal } = options;
|
|
5532
|
+
// #1783: second heartbeat for the drift backstop. The per-file touch
|
|
5533
|
+
// below already carries one, but a round that answers every file from
|
|
5534
|
+
// the #671 cache never calls touchFile at all — and that is precisely the
|
|
5535
|
+
// round whose stale answers the pi-codec witness recorded. Same rate
|
|
5536
|
+
// limit, same non-awaited contract.
|
|
5537
|
+
void this.sweepDocumentDrift().catch(() => { });
|
|
3670
5538
|
// Cap the per-file LSP sweep: a Next.js-scale project can route thousands
|
|
3671
5539
|
// of files through the language server at concurrency 8, and without a
|
|
3672
5540
|
// caller cap that grinds for tens of minutes (#341). `maxFiles` lets
|
|
@@ -3742,6 +5610,16 @@ export class LSPService {
|
|
|
3742
5610
|
if (cachedResults.length > 0) {
|
|
3743
5611
|
options.onProgress?.(completed, files.length);
|
|
3744
5612
|
}
|
|
5613
|
+
// #1782: which files this sweep is REPLAYING from cache, and which of those
|
|
5614
|
+
// a workspace pull then explicitly re-answered clean. A project-wide pull
|
|
5615
|
+
// report names files far beyond the group that asked for it; an explicit
|
|
5616
|
+
// zero-diagnostic answer for a replayed file is authoritative and must
|
|
5617
|
+
// supersede the replay, in the sweep's result list AND on disk. Without
|
|
5618
|
+
// this, a server that re-checked a file and found it clean could not
|
|
5619
|
+
// dislodge the stale entry by any means available to a user — the
|
|
5620
|
+
// 2026-08-20 dogfood's 23:07 record.
|
|
5621
|
+
const cacheServedKeys = new Set(cachedResults.map((result) => normalizeMapKey(result.filePath)));
|
|
5622
|
+
const supersededCacheKeys = new Set();
|
|
3745
5623
|
// Per-file scan mtime captured as each file completes below, so a
|
|
3746
5624
|
// confirmed fresh result can be written back into the cache with the
|
|
3747
5625
|
// mtime it was ACTUALLY scanned at (not re-stat'd after the fact, which
|
|
@@ -3858,8 +5736,27 @@ export class LSPService {
|
|
|
3858
5736
|
const preOpenAttempt = withDeadline((async () => {
|
|
3859
5737
|
const { clients } = await this.getClientsForFile(filePath, WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS);
|
|
3860
5738
|
for (const entry of clients) {
|
|
5739
|
+
// #1714: this pass is the sweep's SECOND source of `didOpen`
|
|
5740
|
+
// volume, and it reaches the server without passing the drain
|
|
5741
|
+
// barrier. Charge it to the same backlog ledger, and leave a
|
|
5742
|
+
// scanner that is already at its ceiling out of the burst.
|
|
5743
|
+
let auxKey;
|
|
5744
|
+
if (entry.info.role === "auxiliary") {
|
|
5745
|
+
auxKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
5746
|
+
if (auxKey && this.auxNotifyBacklogAtCeiling(auxKey, entry)) {
|
|
5747
|
+
continue;
|
|
5748
|
+
}
|
|
5749
|
+
}
|
|
3861
5750
|
try {
|
|
3862
5751
|
await entry.client.notify.open(filePath, content, languageId);
|
|
5752
|
+
if (auxKey)
|
|
5753
|
+
this.noteAuxNotifyIssued(auxKey, entry.client);
|
|
5754
|
+
// #1783: deliberately NOT recorded for the drift backstop.
|
|
5755
|
+
// This pass can skip a scanner at its backlog ceiling, so its
|
|
5756
|
+
// coverage is partial by design, and `processFile` runs
|
|
5757
|
+
// `touchFile` over every file in the group immediately after
|
|
5758
|
+
// — that call records with a full-coverage check. Stamping
|
|
5759
|
+
// here would only claim a coverage this loop does not have.
|
|
3863
5760
|
}
|
|
3864
5761
|
catch {
|
|
3865
5762
|
// Best-effort: a failed pre-open just means processFile's own
|
|
@@ -3883,6 +5780,26 @@ export class LSPService {
|
|
|
3883
5780
|
: preOpenAttempt);
|
|
3884
5781
|
}
|
|
3885
5782
|
};
|
|
5783
|
+
// #1618: a service destroyed mid-sweep (idle-reset race, an explicit
|
|
5784
|
+
// `resetLSPService` call, session replacement, …) must stop the loop for
|
|
5785
|
+
// every file it has not yet reached and record WHY — never a bare
|
|
5786
|
+
// `timedOut` that reads identically to a budget timeout. Cheap: no
|
|
5787
|
+
// language-server round trip, just a results push.
|
|
5788
|
+
const markServiceDestroyed = (remainingFiles) => {
|
|
5789
|
+
for (const filePath of remainingFiles) {
|
|
5790
|
+
results.push({
|
|
5791
|
+
filePath,
|
|
5792
|
+
diagnostics: [],
|
|
5793
|
+
count: 0,
|
|
5794
|
+
timedOut: true,
|
|
5795
|
+
unconfirmedReason: "service_destroyed",
|
|
5796
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
5797
|
+
});
|
|
5798
|
+
timedOutFiles += 1;
|
|
5799
|
+
completed += 1;
|
|
5800
|
+
}
|
|
5801
|
+
options.onProgress?.(completed, files.length);
|
|
5802
|
+
};
|
|
3886
5803
|
const processFile = async (filePath) => {
|
|
3887
5804
|
try {
|
|
3888
5805
|
const content = contentCache.get(filePath) ??
|
|
@@ -3954,20 +5871,37 @@ export class LSPService {
|
|
|
3954
5871
|
// `perFileMs` deadline, which only catches a touch that never returned at
|
|
3955
5872
|
// all within budget. Either one means the result wasn't confirmed.
|
|
3956
5873
|
const inconclusive = touchResult?.inconclusive === true;
|
|
3957
|
-
// #1470:
|
|
3958
|
-
//
|
|
5874
|
+
// #1470/#1493: an auxiliary that never reported — cut off by the grace
|
|
5875
|
+
// timer, or silent with nothing published for this content — is the
|
|
5876
|
+
// THIRD reason this result is not a confirmed observation, and it is
|
|
5877
|
+
// deliberately not `inconclusive`. The
|
|
3959
5878
|
// record loop below persists every `!timedOut` result into the workspace
|
|
3960
5879
|
// cache, so reading `inconclusive` alone caches a partially covered
|
|
3961
|
-
// answer as clean and replays it on every later sweep.
|
|
3962
|
-
//
|
|
3963
|
-
//
|
|
3964
|
-
//
|
|
3965
|
-
//
|
|
3966
|
-
//
|
|
3967
|
-
|
|
3968
|
-
|
|
5880
|
+
// answer as clean and replays it on every later sweep. The warm-attach
|
|
5881
|
+
// incumbent reaches this branch through the aux GRACE wait (its touch
|
|
5882
|
+
// runs `clientScope: "with-auxiliary"`), where all three no-answer shapes
|
|
5883
|
+
// arise. #1459 added the two pre-wait doors for the sweep's own local
|
|
5884
|
+
// `clientScope: "all"` touch — a scanner whose breaker was open, or whose
|
|
5885
|
+
// resync the fan-out gate deferred. #1533 closed the last hole: `"all"`
|
|
5886
|
+
// still never enters the grace wait, but it now derives the SAME evidence
|
|
5887
|
+
// from post-wait state, so a silent auxiliary narrows this scope too
|
|
5888
|
+
// instead of aggregating as a confirmed clean. Every route is gated here.
|
|
5889
|
+
const unconfirmedServerIds = touchCoverageGap(touchResult);
|
|
5890
|
+
const coverageGap = unconfirmedServerIds.length > 0;
|
|
5891
|
+
// #1549: the sweep verdict is per answering lane. An auxiliary gap
|
|
5892
|
+
// narrows coverage, but a primary answer remains usable; only absence of
|
|
5893
|
+
// the touch result or a primary-scoped inconclusive verdict poisons it.
|
|
5894
|
+
const timedOut = touchResult === undefined || inconclusive;
|
|
3969
5895
|
if (timedOut)
|
|
3970
5896
|
timedOutFiles += 1;
|
|
5897
|
+
// #1618: WHY, in priority order — the outer deadline (nothing came
|
|
5898
|
+
// back at all) outranks an inner inconclusive signal, which outranks
|
|
5899
|
+
// a narrower auxiliary coverage gap.
|
|
5900
|
+
const unconfirmedReason = touchResult === undefined
|
|
5901
|
+
? "budget"
|
|
5902
|
+
: inconclusive
|
|
5903
|
+
? "inconclusive"
|
|
5904
|
+
: undefined;
|
|
3971
5905
|
// #1104 (shape 5 — AGENTS.md): the touch's content binding is an
|
|
3972
5906
|
// EXPLICIT enumerable field on the wrapper now (#1179), so it survives
|
|
3973
5907
|
// `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
|
|
@@ -3997,7 +5931,10 @@ export class LSPService {
|
|
|
3997
5931
|
filePath,
|
|
3998
5932
|
diagnostics: filteredDiagnostics ?? [],
|
|
3999
5933
|
count: filteredDiagnostics?.length ?? 0,
|
|
4000
|
-
timedOut,
|
|
5934
|
+
...(timedOut && { timedOut: true, unconfirmedReason }),
|
|
5935
|
+
...(coverageGap && {
|
|
5936
|
+
unconfirmedServerIds: [...unconfirmedServerIds],
|
|
5937
|
+
}),
|
|
4001
5938
|
contentHash: rawBinding?.contentHash,
|
|
4002
5939
|
boundToCurrentDisk: rawBinding?.boundToCurrentDisk,
|
|
4003
5940
|
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
@@ -4011,8 +5948,12 @@ export class LSPService {
|
|
|
4011
5948
|
error: err instanceof Error ? err.message : String(err),
|
|
4012
5949
|
// An errored check is exactly as inconclusive as a timed-out one —
|
|
4013
5950
|
// no confirmed result was obtained, so reconciliation (#571) must
|
|
4014
|
-
// skip it the same way.
|
|
5951
|
+
// skip it the same way. #1618: distinct from every OTHER
|
|
5952
|
+
// `timedOut: true` reason — an `error` must never render as "didn't
|
|
5953
|
+
// complete within budget" (the old dead-subtraction bug in
|
|
5954
|
+
// tools/lens-diagnostics.ts's `unconfirmedErrored` computation).
|
|
4015
5955
|
timedOut: true,
|
|
5956
|
+
unconfirmedReason: "error",
|
|
4016
5957
|
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
4017
5958
|
});
|
|
4018
5959
|
}
|
|
@@ -4046,13 +5987,29 @@ export class LSPService {
|
|
|
4046
5987
|
await runPerServerGroups(groups, groupWorkers, async (group) => {
|
|
4047
5988
|
if (signal?.aborted)
|
|
4048
5989
|
return;
|
|
5990
|
+
// #1618: checked before any per-group work (pull attempt, warm-up,
|
|
5991
|
+
// per-file loop) so a service already destroyed when this group
|
|
5992
|
+
// starts never pays for a language-server round trip it cannot get.
|
|
5993
|
+
if (this.checkDestroyed()) {
|
|
5994
|
+
markServiceDestroyed(group.files);
|
|
5995
|
+
return;
|
|
5996
|
+
}
|
|
4049
5997
|
// Fast path: one project-wide pull for the whole group (opt-in).
|
|
4050
5998
|
if (!isWarmAttached() &&
|
|
4051
5999
|
workspacePullEnabled &&
|
|
4052
6000
|
!group.multiServer) {
|
|
4053
|
-
const pulled = await this.tryWorkspacePull(group.files, perFileMs);
|
|
6001
|
+
const pulled = await this.tryWorkspacePull(group.files, perFileMs, cacheServedKeys);
|
|
4054
6002
|
if (pulled) {
|
|
4055
|
-
|
|
6003
|
+
// #1782: an explicit zero-diagnostic answer for a file this
|
|
6004
|
+
// sweep served from cache SUPERSEDES that cached replay. Route
|
|
6005
|
+
// it through the same result list every other answer uses, so
|
|
6006
|
+
// the cache write below overwrites the stale entry and the
|
|
6007
|
+
// footer reconcile in `tools/lens-diagnostics.ts` clears the
|
|
6008
|
+
// widget rows — no second eviction path to keep in step.
|
|
6009
|
+
for (const clean of pulled.extraClean) {
|
|
6010
|
+
supersededCacheKeys.add(normalizeMapKey(clean.filePath));
|
|
6011
|
+
}
|
|
6012
|
+
for (const result of [...pulled.results, ...pulled.extraClean]) {
|
|
4056
6013
|
results.push({
|
|
4057
6014
|
...result,
|
|
4058
6015
|
writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
|
|
@@ -4117,6 +6074,7 @@ export class LSPService {
|
|
|
4117
6074
|
diagnostics: [],
|
|
4118
6075
|
count: 0,
|
|
4119
6076
|
timedOut: true,
|
|
6077
|
+
unconfirmedReason: "inconclusive",
|
|
4120
6078
|
skippedWarmupFailure: true,
|
|
4121
6079
|
});
|
|
4122
6080
|
timedOutFiles += 1;
|
|
@@ -4138,6 +6096,15 @@ export class LSPService {
|
|
|
4138
6096
|
for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
|
|
4139
6097
|
if (signal?.aborted)
|
|
4140
6098
|
return;
|
|
6099
|
+
// #1618: a service destroyed WHILE this group's chunk loop was
|
|
6100
|
+
// already running (the group-start check above can't see a
|
|
6101
|
+
// destruction that lands mid-loop) — stop here and mark every
|
|
6102
|
+
// file from this chunk onward, rather than letting the remaining
|
|
6103
|
+
// chunks pay for pre-opens/touches against a torn-down service.
|
|
6104
|
+
if (this.checkDestroyed()) {
|
|
6105
|
+
markServiceDestroyed(group.files.slice(chunkStart));
|
|
6106
|
+
return;
|
|
6107
|
+
}
|
|
4141
6108
|
const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
|
|
4142
6109
|
await preOpenGroupFiles(chunk);
|
|
4143
6110
|
for (const filePath of chunk) {
|
|
@@ -4145,10 +6112,33 @@ export class LSPService {
|
|
|
4145
6112
|
// results are returned as a partial.
|
|
4146
6113
|
if (signal?.aborted)
|
|
4147
6114
|
return;
|
|
6115
|
+
if (this.checkDestroyed()) {
|
|
6116
|
+
markServiceDestroyed(group.files.slice(group.files.indexOf(filePath)));
|
|
6117
|
+
return;
|
|
6118
|
+
}
|
|
4148
6119
|
await processFile(filePath);
|
|
4149
6120
|
}
|
|
4150
6121
|
}
|
|
4151
6122
|
}, signal);
|
|
6123
|
+
// #1618: per-reason tally alongside the flat `timedOutFiles` count (kept
|
|
6124
|
+
// for the existing `scripts/analyze-pi-lens-logs.mjs` consumer) — a
|
|
6125
|
+
// dashboard reading only the flat count can no longer mistake 81
|
|
6126
|
+
// service-destroyed files for 81 budget-exhausted ones.
|
|
6127
|
+
const unconfirmedByReason = {};
|
|
6128
|
+
for (const result of results) {
|
|
6129
|
+
if (!result.timedOut)
|
|
6130
|
+
continue;
|
|
6131
|
+
const reason = result.unconfirmedReason ?? "budget";
|
|
6132
|
+
unconfirmedByReason[reason] = (unconfirmedByReason[reason] ?? 0) + 1;
|
|
6133
|
+
}
|
|
6134
|
+
const partiallyCoveredFiles = results.filter((result) => (result.unconfirmedServerIds?.length ?? 0) > 0).length;
|
|
6135
|
+
// Code-unit comparator (#1883): this list ships as the
|
|
6136
|
+
// `unconfirmedServerIds` field on the `lsp_workspace_diagnostics` record,
|
|
6137
|
+
// so its order must be deterministic across locales — localeCompare is
|
|
6138
|
+
// deliberately avoided.
|
|
6139
|
+
const unconfirmedServerIds = [
|
|
6140
|
+
...new Set(results.flatMap((result) => result.unconfirmedServerIds ?? [])),
|
|
6141
|
+
].sort((a, b) => Number(a > b) - Number(a < b));
|
|
4152
6142
|
logLatency({
|
|
4153
6143
|
type: "phase",
|
|
4154
6144
|
phase: "lsp_workspace_diagnostics",
|
|
@@ -4162,6 +6152,9 @@ export class LSPService {
|
|
|
4162
6152
|
concurrency: groupWorkers,
|
|
4163
6153
|
maxFiles,
|
|
4164
6154
|
timedOutFiles,
|
|
6155
|
+
unconfirmedByReason,
|
|
6156
|
+
partiallyCoveredFiles,
|
|
6157
|
+
...(unconfirmedServerIds.length > 0 && { unconfirmedServerIds }),
|
|
4165
6158
|
aborted: signal?.aborted ?? false,
|
|
4166
6159
|
},
|
|
4167
6160
|
});
|
|
@@ -4178,8 +6171,12 @@ export class LSPService {
|
|
|
4178
6171
|
// freshness check; nothing here needs to explicitly evict them).
|
|
4179
6172
|
for (const result of results) {
|
|
4180
6173
|
const scannedAt = scannedMtimeByFile.get(result.filePath);
|
|
4181
|
-
if (result.error ||
|
|
6174
|
+
if (result.error ||
|
|
6175
|
+
result.timedOut ||
|
|
6176
|
+
(result.unconfirmedServerIds?.length ?? 0) > 0 ||
|
|
6177
|
+
scannedAt === undefined) {
|
|
4182
6178
|
continue;
|
|
6179
|
+
}
|
|
4183
6180
|
// #1104: thread the per-result `contentHash` (from either the
|
|
4184
6181
|
// `tryWorkspacePull` fast path or a per-file touch's own #1095 binding)
|
|
4185
6182
|
// into the cache entry — previously this call never passed one, so
|
|
@@ -4191,15 +6188,37 @@ export class LSPService {
|
|
|
4191
6188
|
workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
|
|
4192
6189
|
}
|
|
4193
6190
|
workspaceDiagnosticsCacheCtx.persist();
|
|
4194
|
-
|
|
6191
|
+
// #1782: drop every cached replay a pull answered clean — returning both
|
|
6192
|
+
// would hand the footer reconcile two contradictory results for one file,
|
|
6193
|
+
// and the stale one could win on ordering.
|
|
6194
|
+
const servedCacheResults = supersededCacheKeys.size === 0
|
|
6195
|
+
? cachedResults
|
|
6196
|
+
: cachedResults.filter((result) => !supersededCacheKeys.has(normalizeMapKey(result.filePath)));
|
|
6197
|
+
return [...servedCacheResults, ...results].filter(Boolean);
|
|
4195
6198
|
}
|
|
4196
6199
|
/**
|
|
4197
6200
|
* #387 Item 2: one `workspace/diagnostic` pull covering a whole server group,
|
|
4198
6201
|
* instead of N per-file opens. Returns per-file results (files absent from the
|
|
4199
6202
|
* report are reported clean), or `undefined` when the server doesn't advertise
|
|
4200
6203
|
* workspace pull / the pull fails — the caller then falls back to per-file.
|
|
6204
|
+
*
|
|
6205
|
+
* #1782: the report is project-wide, so it routinely names files this group
|
|
6206
|
+
* never asked about — including files this sweep already served from the
|
|
6207
|
+
* cache. Those answers used to be dropped on the floor: the mapping below
|
|
6208
|
+
* only ever looked up `groupFiles`. That is how a server can explicitly
|
|
6209
|
+
* re-answer a file with ZERO diagnostics while the cache and the widget keep
|
|
6210
|
+
* rendering its stale blockers, which is what the 2026-08-20 dogfood recorded
|
|
6211
|
+
* at 23:07. `reanswerFor` opts a caller into those extra answers: pass the
|
|
6212
|
+
* normalized keys of files served from cache, and any of them the report
|
|
6213
|
+
* explicitly names with zero diagnostics comes back in `extraClean` as a
|
|
6214
|
+
* confirmed clean result.
|
|
6215
|
+
*
|
|
6216
|
+
* Only an EXPLICIT zero-diagnostic entry qualifies. Absence from the report
|
|
6217
|
+
* reads as clean for `groupFiles`, which this sweep did ask about, but it is
|
|
6218
|
+
* genuinely UNKNOWN for a file nobody asked about — a server may report only
|
|
6219
|
+
* what it re-checked.
|
|
4201
6220
|
*/
|
|
4202
|
-
async tryWorkspacePull(groupFiles, perFileMs) {
|
|
6221
|
+
async tryWorkspacePull(groupFiles, perFileMs, reanswerFor) {
|
|
4203
6222
|
try {
|
|
4204
6223
|
const first = groupFiles[0];
|
|
4205
6224
|
if (!first)
|
|
@@ -4213,11 +6232,13 @@ export class LSPService {
|
|
|
4213
6232
|
const report = await spawned.client.requestWorkspaceDiagnostics(Math.max(perFileMs, workspacePullBudgetMs()));
|
|
4214
6233
|
if (!report)
|
|
4215
6234
|
return undefined;
|
|
6235
|
+
// Last-wins per file: the report builder does not dedup, so a server
|
|
6236
|
+
// naming the same URI twice appears twice in `report` (#1786 review F2).
|
|
4216
6237
|
const byPath = new Map();
|
|
4217
6238
|
for (const entry of report) {
|
|
4218
6239
|
byPath.set(normalizeMapKey(entry.filePath), entry);
|
|
4219
6240
|
}
|
|
4220
|
-
|
|
6241
|
+
const results = groupFiles.map((filePath) => {
|
|
4221
6242
|
const entry = byPath.get(normalizeMapKey(filePath));
|
|
4222
6243
|
const diagnostics = entry?.diagnostics ?? [];
|
|
4223
6244
|
// A pull that got here returned a real workspace/diagnostic report
|
|
@@ -4234,6 +6255,48 @@ export class LSPService {
|
|
|
4234
6255
|
contentHash: entry?.contentHash,
|
|
4235
6256
|
};
|
|
4236
6257
|
});
|
|
6258
|
+
// #1782: harvest explicit clean answers for files this group never asked
|
|
6259
|
+
// about but the caller is serving from cache. Same confirmed status as
|
|
6260
|
+
// the mapping above — it is the same report.
|
|
6261
|
+
//
|
|
6262
|
+
// #1786 review F2: iterate `byPath`, not `report`. The report BUILDER
|
|
6263
|
+
// (`clients/lsp/client.ts`'s `requestWorkspaceDiagnostics`) pushes one
|
|
6264
|
+
// output entry per report item with no dedup, so a server that names the
|
|
6265
|
+
// same URI twice yields two entries for one file. Walking the raw list
|
|
6266
|
+
// would emit two results for one file — breaking the caller's
|
|
6267
|
+
// one-result-per-file invariant — and would let a zero-diagnostic
|
|
6268
|
+
// duplicate evict a cached blocker that the SAME report also reports as
|
|
6269
|
+
// still failing. `byPath` is last-wins and unique per file, and
|
|
6270
|
+
// `withFindings` refuses eviction whenever ANY entry for that file
|
|
6271
|
+
// reports findings, whatever the order. Refusing costs a stale entry one
|
|
6272
|
+
// more sweep; evicting on a contradicted answer discards a live blocker.
|
|
6273
|
+
const extraClean = [];
|
|
6274
|
+
if (reanswerFor && reanswerFor.size > 0) {
|
|
6275
|
+
const asked = new Set(groupFiles.map((f) => normalizeMapKey(f)));
|
|
6276
|
+
const withFindings = new Set();
|
|
6277
|
+
for (const entry of report) {
|
|
6278
|
+
if (entry.diagnostics.length > 0) {
|
|
6279
|
+
withFindings.add(normalizeMapKey(entry.filePath));
|
|
6280
|
+
}
|
|
6281
|
+
}
|
|
6282
|
+
for (const [key, entry] of byPath) {
|
|
6283
|
+
// The membership filter is load-bearing: a report names files far
|
|
6284
|
+
// beyond this sweep, and a file nobody asked about and nothing is
|
|
6285
|
+
// replaying has no business entering the sweep's results.
|
|
6286
|
+
if (asked.has(key) || !reanswerFor.has(key))
|
|
6287
|
+
continue;
|
|
6288
|
+
if (entry.diagnostics.length > 0 || withFindings.has(key))
|
|
6289
|
+
continue;
|
|
6290
|
+
extraClean.push({
|
|
6291
|
+
filePath: entry.filePath,
|
|
6292
|
+
diagnostics: [],
|
|
6293
|
+
count: 0,
|
|
6294
|
+
timedOut: false,
|
|
6295
|
+
contentHash: entry.contentHash,
|
|
6296
|
+
});
|
|
6297
|
+
}
|
|
6298
|
+
}
|
|
6299
|
+
return { results, extraClean };
|
|
4237
6300
|
}
|
|
4238
6301
|
catch {
|
|
4239
6302
|
return undefined;
|
|
@@ -4317,6 +6380,35 @@ export class LSPService {
|
|
|
4317
6380
|
supportsLSP(filePath) {
|
|
4318
6381
|
return getServersForFileWithConfig(filePath).length > 0;
|
|
4319
6382
|
}
|
|
6383
|
+
/**
|
|
6384
|
+
* Check whether the PRIMARY server for this file is currently mid-spawn
|
|
6385
|
+
* (`state.inFlight`, keyed `${server.id}:${normalizeMapKey(root)}` — see
|
|
6386
|
+
* :2413). Lets a caller whose own wait budget expires distinguish "the
|
|
6387
|
+
* server hasn't finished its first spawn yet" from "the server is running
|
|
6388
|
+
* but slow/wedged" (#1766) — a cold spawn still in flight is not a verdict
|
|
6389
|
+
* on a server that doesn't exist yet.
|
|
6390
|
+
*
|
|
6391
|
+
* Mirrors the `role !== "auxiliary"` filter getClientForFile applies at
|
|
6392
|
+
* :2146-2148 (kept coupled to that line intentionally): auxiliary servers
|
|
6393
|
+
* (opengrep, typos, …) spawn routinely and concurrently with an ALREADY
|
|
6394
|
+
* ALIVE primary (dispatch/runners/lsp.ts's with-auxiliary path fires one
|
|
6395
|
+
* per edit for most files via TYPOS_EXTENSIONS). Without this filter, an
|
|
6396
|
+
* unrelated auxiliary spawn would downgrade a genuinely wedged primary to
|
|
6397
|
+
* a benign "spawn-in-flight" verdict — the worse misreport direction.
|
|
6398
|
+
*
|
|
6399
|
+
* The touch path supplies roots resolved before acquisition. Matching the full
|
|
6400
|
+
* server/root key prevents another workspace's spawn from relabeling this one.
|
|
6401
|
+
* Root resolution is already complete, and this check stays synchronous.
|
|
6402
|
+
* The failure mode is limited to the matching workspace key.
|
|
6403
|
+
* Pure lookup — does not spawn or wait for a client.
|
|
6404
|
+
*/
|
|
6405
|
+
isSpawnInFlight(_filePath, resolvedRoots = new Map()) {
|
|
6406
|
+
for (const [serverId, root] of resolvedRoots) {
|
|
6407
|
+
if (this.state.inFlight.has(`${serverId}:${root}`))
|
|
6408
|
+
return true;
|
|
6409
|
+
}
|
|
6410
|
+
return false;
|
|
6411
|
+
}
|
|
4320
6412
|
/**
|
|
4321
6413
|
* Check whether an LSP client is already alive for a file.
|
|
4322
6414
|
* Lightweight — does not spawn or wait for a client.
|
|
@@ -4387,6 +6479,21 @@ export class LSPService {
|
|
|
4387
6479
|
});
|
|
4388
6480
|
this.state.clients.clear();
|
|
4389
6481
|
this.state.broken.clear();
|
|
6482
|
+
// #1459: every gated client is gone, so no outstanding-write record can
|
|
6483
|
+
// describe a live one. The gate's identity check already neutralises a stale
|
|
6484
|
+
// entry; clearing keeps the map honest rather than relying on that.
|
|
6485
|
+
this.outstandingAuxNotifyWrites.clear();
|
|
6486
|
+
// #1714: same reasoning — a backlog count belongs to a client generation,
|
|
6487
|
+
// and every client is gone. `session_start` reaches this through the service
|
|
6488
|
+
// reset, so the pacing state re-arms with the session rather than living for
|
|
6489
|
+
// the process.
|
|
6490
|
+
this.auxNotifyInflight.clear();
|
|
6491
|
+
// #1783: the drift records describe what THESE clients hold. Every client
|
|
6492
|
+
// is gone, so every record is a claim about a dead server's view. Keeping
|
|
6493
|
+
// them would make the first sweep of the next generation resync files no
|
|
6494
|
+
// one has open yet, and — like the pacing state above — this is state that
|
|
6495
|
+
// must re-arm at `session_start`, which reaches it through this reset.
|
|
6496
|
+
this.documentDrift.clear();
|
|
4390
6497
|
this.workspaceProbeLogged.clear();
|
|
4391
6498
|
this.warmStartLogged.clear();
|
|
4392
6499
|
}
|
|
@@ -4542,7 +6649,27 @@ export function getLSPService() {
|
|
|
4542
6649
|
export async function isAuxiliaryLspAlive(serverId, filePath) {
|
|
4543
6650
|
return getLSPService().isServerAliveForFile(serverId, filePath);
|
|
4544
6651
|
}
|
|
6652
|
+
/**
|
|
6653
|
+
* Cross-layer seam (#1668) for callers outside `lsp/` (bash/write tool-result
|
|
6654
|
+
* handling) that observe a disk change no open-document sync path will ever
|
|
6655
|
+
* report — an external delete/create/modify. Delivers to already-active
|
|
6656
|
+
* clients only; see `LSPService.notifyExternalFileChange`.
|
|
6657
|
+
*/
|
|
6658
|
+
export async function notifyExternalFileChange(filePath, type) {
|
|
6659
|
+
return getLSPService().notifyExternalFileChange(filePath, type);
|
|
6660
|
+
}
|
|
4545
6661
|
export function resetLSPService(options = {}) {
|
|
6662
|
+
// A new session must get its own classic-tsserver-repair attempt: the
|
|
6663
|
+
// guard is a process-lifetime flag (see resetClassicTsRepairGuard), so a
|
|
6664
|
+
// repair that failed transiently in an earlier session must not stay
|
|
6665
|
+
// latched for the rest of the extension-host process (#1570).
|
|
6666
|
+
if (options.reason === "session_start") {
|
|
6667
|
+
resetClassicTsRepairGuard();
|
|
6668
|
+
// #1618 (R3): a hold from a PRIOR generation's sweep must never survive
|
|
6669
|
+
// a session boundary — state that must re-arm at session_start cannot
|
|
6670
|
+
// hide behind a leaked/stuck guard from the generation before it.
|
|
6671
|
+
clearWorkspaceSweepHoldForSessionStart();
|
|
6672
|
+
}
|
|
4546
6673
|
const retiringService = globalLSPService;
|
|
4547
6674
|
globalLSPService = null;
|
|
4548
6675
|
if (!retiringService)
|