@herbertgao/pi-extensions 2026.8.5 → 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 +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
- 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/feature/context.ts +74 -5
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +48 -6
- 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 +16 -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 +22 -15
- 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 +34 -10
- package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +29 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +3 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +46 -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 +8 -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 +119 -103
- 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 +13 -9
- package/node_modules/@narumitw/pi-btw/src/btw.ts +40 -42
- 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 +78 -15
- package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
- package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
- package/node_modules/pi-lens/CHANGELOG.md +1004 -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 +427 -0
- package/node_modules/pi-lens/dist/clients/agent-nudge.js +79 -16
- 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 +176 -37
- 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/bus-events-logger.js +62 -6
- package/node_modules/pi-lens/dist/clients/bus-publish.js +11 -3
- package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
- package/node_modules/pi-lens/dist/clients/cascade-format.js +76 -2
- 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/console-guard-install.js +16 -4
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +249 -52
- 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 +43 -9
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +153 -37
- package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
- package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +165 -11
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +325 -72
- 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 +81 -41
- 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 +488 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +84 -0
- 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 +943 -93
- 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 +106 -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/disposition-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
- package/node_modules/pi-lens/dist/clients/extension-log.js +317 -3
- 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/fix-worklog.js +5 -1
- package/node_modules/pi-lens/dist/clients/format-events-publish.js +39 -8
- 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 +49 -21
- package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
- package/node_modules/pi-lens/dist/clients/go-client.js +21 -39
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +219 -10
- package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
- package/node_modules/pi-lens/dist/clients/host-ports.js +9 -1
- package/node_modules/pi-lens/dist/clients/installer/index.js +1119 -101
- 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 +86 -15
- package/node_modules/pi-lens/dist/clients/knip-client.js +361 -24
- 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 +487 -9
- 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-events.js +56 -25
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
- package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +45 -2
- package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +160 -20
- package/node_modules/pi-lens/dist/clients/lsp/client.js +1666 -148
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +111 -0
- package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
- package/node_modules/pi-lens/dist/clients/lsp/index.js +2496 -171
- 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 +517 -69
- 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 +172 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +61 -3
- 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/analyze.js +4 -0
- package/node_modules/pi-lens/dist/clients/mcp/session.js +39 -18
- package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
- 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 +178 -30
- 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 +85 -15
- 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/runner-findings.js +23 -2
- 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 +76 -4
- package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +248 -13
- package/node_modules/pi-lens/dist/clients/runtime-context.js +112 -12
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +472 -61
- package/node_modules/pi-lens/dist/clients/runtime-session.js +359 -13
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +269 -22
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +948 -87
- package/node_modules/pi-lens/dist/clients/rust-client.js +21 -37
- 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 +224 -8
- package/node_modules/pi-lens/dist/clients/sg-runner.js +249 -37
- package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +18 -11
- package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
- package/node_modules/pi-lens/dist/clients/startup-timing.js +26 -2
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +574 -32
- package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
- package/node_modules/pi-lens/dist/clients/tool-policy.js +127 -20
- package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
- 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/warm-attach.js +17 -0
- package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
- package/node_modules/pi-lens/dist/clients/word-index.js +305 -33
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
- package/node_modules/pi-lens/dist/index.js +64677 -49484
- package/node_modules/pi-lens/dist/mcp/server.js +8 -2
- package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
- package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
- package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +469 -56
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +190 -46
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
- package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
- package/node_modules/pi-lens/docs/agent-guide.md +44 -14
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +518 -501
- package/node_modules/pi-lens/docs/dispositions.md +21 -0
- package/node_modules/pi-lens/docs/features.md +33 -6
- package/node_modules/pi-lens/docs/globalconfig.md +14 -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 +25 -1
- package/node_modules/pi-lens/docs/usage.md +24 -5
- package/node_modules/pi-lens/docs/word-index.md +35 -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 +22 -0
- 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-conditional-empty-object-spread-js-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
- 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 +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +9 -0
- 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-laundering-test.yml +11 -0
- 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 +66 -0
- 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-conditional-empty-object-spread-js.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
- 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 +29 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +30 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +41 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +42 -0
- 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 +32 -0
- 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 +132 -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 +12 -11
- package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +87 -116
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +170 -0
- package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
- package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +49 -0
- package/node_modules/pi-mcp-adapter/README.md +51 -6
- package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
- package/node_modules/pi-mcp-adapter/cli.js +188 -5
- package/node_modules/pi-mcp-adapter/commands.ts +128 -22
- package/node_modules/pi-mcp-adapter/config.ts +28 -10
- package/node_modules/pi-mcp-adapter/direct-tools.ts +23 -19
- 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/mcp-setup-panel.ts +2 -1
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +46 -25
- package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
- package/node_modules/pi-mcp-adapter/package.json +4 -1
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +330 -0
- package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
- package/node_modules/pi-mcp-adapter/server-manager.ts +145 -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-metadata.ts +38 -18
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
- package/node_modules/pi-mcp-adapter/types.ts +95 -10
- 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 +37 -0
- package/node_modules/pi-web-access/README.md +38 -14
- package/node_modules/pi-web-access/anysearch.ts +4 -2
- package/node_modules/pi-web-access/auth-fetch.ts +148 -0
- package/node_modules/pi-web-access/brave.ts +16 -4
- package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
- package/node_modules/pi-web-access/curator-page.ts +8 -3
- package/node_modules/pi-web-access/curator-server.ts +5 -1
- package/node_modules/pi-web-access/exa.ts +18 -7
- package/node_modules/pi-web-access/extract.ts +134 -37
- package/node_modules/pi-web-access/fetch-params.ts +17 -3
- package/node_modules/pi-web-access/firecrawl.ts +172 -12
- package/node_modules/pi-web-access/gemini-search.ts +33 -7
- package/node_modules/pi-web-access/index.ts +160 -104
- package/node_modules/pi-web-access/package.json +2 -2
- 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 +51 -23
- 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/node_modules/pi-web-access/youtube-extract.ts +2 -2
- package/package.json +13 -13
|
@@ -7,26 +7,32 @@
|
|
|
7
7
|
* - Diagnostics with debouncing
|
|
8
8
|
* - Request/response handling
|
|
9
9
|
*/
|
|
10
|
-
import { logExtension } from "../extension-log.js";
|
|
11
10
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
12
11
|
import { EventEmitter } from "node:events";
|
|
13
12
|
import { access, readFile } from "node:fs/promises";
|
|
14
13
|
import * as os from "node:os";
|
|
15
14
|
import { pathToFileURL } from "node:url";
|
|
15
|
+
import { emitBounded } from "../bounded-telemetry.js";
|
|
16
16
|
import { withTimeout } from "../deadline-utils.js";
|
|
17
|
-
import {
|
|
17
|
+
import { incrementDegradationCount } from "../degradation-ledger.js";
|
|
18
18
|
// vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
|
|
19
19
|
// `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
|
|
20
20
|
import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
|
|
21
|
+
import { logExtension } from "../extension-log.js";
|
|
22
|
+
import { recordLspChild, removeLspChild } from "../instance-registry.js";
|
|
23
|
+
import { logLatency } from "../latency-logger.js";
|
|
24
|
+
import { newLspMutationCorrelationId, } from "../lsp-mutation.js";
|
|
21
25
|
import { getAmbientAbortSignal } from "../safe-spawn.js";
|
|
26
|
+
import { raceToCompletion } from "./aggregation.js";
|
|
22
27
|
import { hashDiagnosticContent, } from "./diagnostic-binding.js";
|
|
23
|
-
import {
|
|
24
|
-
import { applyWorkspaceEdit, normalizeWorkspaceEditToUtf16, } from "./edits.js";
|
|
25
|
-
import { recordLspChild, removeLspChild } from "../instance-registry.js";
|
|
28
|
+
import { applyWorkspaceEdit, normalizeWorkspaceEditToUtf16 } from "./edits.js";
|
|
26
29
|
import { normalizeMapKey, uriToPath } from "./path-utils.js";
|
|
27
30
|
import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
|
|
31
|
+
import { negotiateSyncKind, TEXT_DOCUMENT_SYNC_KIND_FULL, TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL, } from "./sync-kind.js";
|
|
32
|
+
import { probeTsserverProjectIdentity } from "./tsserver-sync.js";
|
|
28
33
|
import { getStrategy } from "./wait-policy/index.js";
|
|
29
34
|
import { WatchedFilesQueue } from "./watch-queue.js";
|
|
35
|
+
import { clearAllWorkspaceDiagnosticsCaches, clearWorkspaceDiagnosticsCacheAtAndAbove, } from "./workspace-diagnostics-cache.js";
|
|
30
36
|
// Opt-in publishDiagnostics trace (PILENS_PUB_DEBUG=1) — read once, negligible
|
|
31
37
|
// hot-path cost. Surfaces each server's publish behavior (version + count) to
|
|
32
38
|
// diagnose the clean-file affirmative-signal question (#240): which servers
|
|
@@ -47,6 +53,8 @@ const PUB_DEBUG = Boolean(process.env.PILENS_PUB_DEBUG);
|
|
|
47
53
|
* argument, without new server-specific code.
|
|
48
54
|
*/
|
|
49
55
|
function extractSpawnMarker(args) {
|
|
56
|
+
if (!args)
|
|
57
|
+
return undefined;
|
|
50
58
|
const tmpDir = os.tmpdir();
|
|
51
59
|
for (let i = 0; i < args.length - 1; i++) {
|
|
52
60
|
const flag = args[i];
|
|
@@ -125,7 +133,31 @@ const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_
|
|
|
125
133
|
// the diagnostics flush. On timeout the request is treated as `unavailable`, which
|
|
126
134
|
// (per #240) is NOT read as clean and falls through to the bounded push backstop.
|
|
127
135
|
const PULL_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS", 10_000);
|
|
136
|
+
// #1773: the smallest budget this codebase treats as usable — dispatching
|
|
137
|
+
// BELOW it is not a real attempt. The old clamp (`Math.max(1, ...)`) let an
|
|
138
|
+
// exhausted `budgetMs` (0 or negative) through as a 1ms pull — sent, timed
|
|
139
|
+
// out by construction, and recorded as a genuine `lsp_pull_diagnostic_timeout`
|
|
140
|
+
// with a fabricated `effectiveBudgetMs: 1` (observed live: both pull-timeout
|
|
141
|
+
// records in the 2026-08-20 plegma dogfood session carried exactly that).
|
|
142
|
+
// 5ms sits below the smallest budget this codebase's own regression fixtures
|
|
143
|
+
// already treat as a real dispatch (`tests/clients/lsp/pull-diagnostic-
|
|
144
|
+
// timeout-telemetry.test.ts` exercises 20ms and 30ms as genuinely-attempted-
|
|
145
|
+
// then-timed-out) and above the 1-4ms band that is indistinguishable from
|
|
146
|
+
// "already exhausted" — a local stdio server round trip (write + compute +
|
|
147
|
+
// parse) needs more than a handful of milliseconds even on the fast path.
|
|
148
|
+
// `budgetMs === PULL_MIN_USABLE_BUDGET_MS` dispatches (the comparison below
|
|
149
|
+
// is strict `<`), so the name reads literally: this is the floor value that
|
|
150
|
+
// still gets a real attempt.
|
|
151
|
+
const PULL_MIN_USABLE_BUDGET_MS = 5;
|
|
128
152
|
const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS", 1000);
|
|
153
|
+
// #1620: the `exit` NOTIFICATION needs its own ceiling, for the same reason the
|
|
154
|
+
// `shutdown` REQUEST has one. A notification write on a pipe that is not
|
|
155
|
+
// draining neither resolves nor rejects, so `safeSendNotification`'s catch never
|
|
156
|
+
// runs and the await is unbounded. The #1459 wedged-write breaker calls this
|
|
157
|
+
// teardown precisely when that stdin has already been PROVEN wedged, so the
|
|
158
|
+
// unbounded await was the whole mechanism of the leak. Kept equal to the request
|
|
159
|
+
// budget: a healthy server still gets a brief graceful window before the kill.
|
|
160
|
+
const EXIT_NOTIFY_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXIT_NOTIFY_TIMEOUT_MS", 1000);
|
|
129
161
|
// #1277: cheap liveness round-trip for the silent-clean gates (`index.ts`).
|
|
130
162
|
// Those gates convert a diagnostics-wait timeout into a confirmed-clean
|
|
131
163
|
// result from a STATIC capability classification (`silentOnClean`) alone —
|
|
@@ -145,12 +177,46 @@ const LIVENESS_PING_QUERY = "__pi_lens_liveness_ping__";
|
|
|
145
177
|
// cheaply rebuilt by the next full pull, the worst case is just one extra full
|
|
146
178
|
// (non-`unchanged`) report per affected file.
|
|
147
179
|
const WORKSPACE_PULL_RESULT_CACHE_MAX = 4096;
|
|
180
|
+
// #1713: `workspace/diagnostic` has no per-file/per-identifier request to key
|
|
181
|
+
// telemetry identity on — it is one project-wide pull — so timeout/late-answer
|
|
182
|
+
// records use this fixed subject/scope instead of a real path.
|
|
183
|
+
const WORKSPACE_PULL_SCOPE = "*workspace*";
|
|
184
|
+
// #1669 review N2: cap on simultaneous re-pulls the `workspace/diagnostic/
|
|
185
|
+
// refresh` handler fires for open documents. A workspace with hundreds of
|
|
186
|
+
// open documents fanning out one `textDocument/diagnostic` request each,
|
|
187
|
+
// with no cap, floods the server the refresh itself just told us is under
|
|
188
|
+
// load. Small and fixed — this is a background improvement after the
|
|
189
|
+
// protocol reply, never something worth tuning per project.
|
|
190
|
+
const REFRESH_REPULL_CONCURRENCY = 4;
|
|
191
|
+
/** Run `mapper` over `items` with at most `concurrency` in flight at once.
|
|
192
|
+
* Same shape as `dependency-checker.ts`'s helper of the same name — a
|
|
193
|
+
* worker-pool pattern repeated per-file by design in this codebase rather
|
|
194
|
+
* than shared, so each caller can keep it un-exported and file-local. */
|
|
195
|
+
async function mapWithConcurrency(items, concurrency, mapper) {
|
|
196
|
+
if (items.length === 0)
|
|
197
|
+
return;
|
|
198
|
+
let nextIndex = 0;
|
|
199
|
+
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
|
200
|
+
const worker = async () => {
|
|
201
|
+
while (true) {
|
|
202
|
+
const index = nextIndex++;
|
|
203
|
+
if (index >= items.length)
|
|
204
|
+
return;
|
|
205
|
+
await mapper(items[index]);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
209
|
+
}
|
|
148
210
|
// Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
|
|
149
211
|
// (30s): the command is mutating and legitimately long-running (a real server
|
|
150
212
|
// refactor / organize-imports), so this must not truncate valid work — it only
|
|
151
213
|
// stops a hung server from blocking the caller forever. On timeout the command
|
|
152
214
|
// may still be applying server-side; we surface that rather than pretend it ran.
|
|
153
215
|
const EXECUTE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS", 30_000);
|
|
216
|
+
// #1412 H1: short ceiling for the read-only tsserver project-identity probe.
|
|
217
|
+
// This is a telemetry sample, not a mutation — it must never hold the door
|
|
218
|
+
// open for anything close to EXECUTE_COMMAND_TIMEOUT_MS.
|
|
219
|
+
const PROBE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PROJECT_IDENTITY_PROBE_TIMEOUT_MS", 2_500);
|
|
154
220
|
const LSP_CRASH_CODES = new Set([
|
|
155
221
|
"ERR_STREAM_DESTROYED",
|
|
156
222
|
"ERR_STREAM_WRITE_AFTER_END",
|
|
@@ -409,10 +475,15 @@ function normalizeLspDiagnostic(diagnostic) {
|
|
|
409
475
|
function normalizeLspDiagnostics(diagnostics) {
|
|
410
476
|
return diagnostics.map(normalizeLspDiagnostic);
|
|
411
477
|
}
|
|
412
|
-
|
|
478
|
+
/**
|
|
479
|
+
* Union of diagnostic lists, first occurrence wins, exact duplicates dropped.
|
|
480
|
+
* Variadic since #1667 so the per-identifier pull sources merge through the
|
|
481
|
+
* SAME key as the push/pull merge rather than growing a second dedupe rule.
|
|
482
|
+
*/
|
|
483
|
+
function mergeDiagnosticLists(...lists) {
|
|
413
484
|
const merged = [];
|
|
414
485
|
const seen = new Set();
|
|
415
|
-
for (const diagnostic of
|
|
486
|
+
for (const diagnostic of lists.flatMap((list) => list ?? [])) {
|
|
416
487
|
const key = [
|
|
417
488
|
diagnostic.range.start.line,
|
|
418
489
|
diagnostic.range.start.character,
|
|
@@ -430,13 +501,191 @@ function mergeDiagnosticLists(push, pull) {
|
|
|
430
501
|
return merged;
|
|
431
502
|
}
|
|
432
503
|
function getMergedDiagnosticsForPath(state, normalizedPath) {
|
|
504
|
+
// SAFETY: `diagnostics` is the pre-split field name that older states (and
|
|
505
|
+
// older embedders holding a state built before `pushDiagnostics` existed)
|
|
506
|
+
// still carry. It is absent from `LSPClientState` on purpose — new code
|
|
507
|
+
// must not write it. The cast declares it OPTIONAL, so the read below is
|
|
508
|
+
// still `undefined`-guarded and a state without it falls through to the
|
|
509
|
+
// current field.
|
|
433
510
|
const legacy = state;
|
|
434
511
|
return mergeDiagnosticLists(state.pushDiagnostics?.get(normalizedPath) ??
|
|
435
512
|
legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath));
|
|
436
513
|
}
|
|
437
|
-
|
|
514
|
+
/**
|
|
515
|
+
* #1667: per-source pull-diagnostic bookkeeping.
|
|
516
|
+
*
|
|
517
|
+
* A pull-mode server can expose several diagnostic SOURCES, each named by its
|
|
518
|
+
* registration's `registerOptions.identifier` (Roslyn: syntax, semantic,
|
|
519
|
+
* analyzers). Each source answers independently, with its own `resultId` and
|
|
520
|
+
* its own findings, so per-path state that used to be a single slot has to
|
|
521
|
+
* become one slot per (path, source):
|
|
522
|
+
*
|
|
523
|
+
* - `pullResultIds` keys on `pullSourceKey(path, identifier)`, so an
|
|
524
|
+
* `unchanged` report is only ever answered against the id THAT source
|
|
525
|
+
* issued (#240/#1104 machinery, now per source).
|
|
526
|
+
* - `documentPullDiagnosticsBySource` holds each source's findings, and
|
|
527
|
+
* `documentPullDiagnostics` is their deduped union - recomputed whenever a
|
|
528
|
+
* source reports, so a fresh answer from one source never wipes another's.
|
|
529
|
+
*
|
|
530
|
+
* The bare (identifier-less) source keys on the plain path, so a single-source
|
|
531
|
+
* server stores exactly what it stored before this change.
|
|
532
|
+
*/
|
|
533
|
+
// NUL is the one byte a filesystem path can never contain, so
|
|
534
|
+
// `path + SEP + identifier` is unambiguous: no path/identifier pair can
|
|
535
|
+
// produce the same key as a different pair, and the bare source's key (the
|
|
536
|
+
// plain path) can never collide with a named source's.
|
|
537
|
+
const PULL_SOURCE_KEY_SEPARATOR = "\u0000";
|
|
538
|
+
const BARE_PULL_SOURCE = "";
|
|
539
|
+
function pullSourceKey(normalizedPath, identifier) {
|
|
540
|
+
return identifier === undefined
|
|
541
|
+
? normalizedPath
|
|
542
|
+
: `${normalizedPath}${PULL_SOURCE_KEY_SEPARATOR}${identifier}`;
|
|
543
|
+
}
|
|
544
|
+
/** Every `pullResultIds` key belonging to a path - the bare key plus one per
|
|
545
|
+
* identifier. Used by the clear path so a resync drops EVERY source's basis. */
|
|
546
|
+
function pullSourceKeysForPath(state, normalizedPath) {
|
|
547
|
+
const prefix = `${normalizedPath}${PULL_SOURCE_KEY_SEPARATOR}`;
|
|
548
|
+
return [
|
|
549
|
+
normalizedPath,
|
|
550
|
+
...[...(state.pullResultIds?.keys() ?? [])].filter((key) => key.startsWith(prefix)),
|
|
551
|
+
];
|
|
552
|
+
}
|
|
553
|
+
function pullSourcesFor(state, normalizedPath) {
|
|
554
|
+
if (!state.documentPullDiagnosticsBySource) {
|
|
555
|
+
state.documentPullDiagnosticsBySource = new Map();
|
|
556
|
+
}
|
|
557
|
+
let sources = state.documentPullDiagnosticsBySource.get(normalizedPath);
|
|
558
|
+
if (!sources) {
|
|
559
|
+
sources = new Map();
|
|
560
|
+
state.documentPullDiagnosticsBySource.set(normalizedPath, sources);
|
|
561
|
+
}
|
|
562
|
+
return sources;
|
|
563
|
+
}
|
|
564
|
+
/** This source's currently stored findings for a path - what an `unchanged`
|
|
565
|
+
* report inherits. Falls back to the whole per-path list when the source map
|
|
566
|
+
* has no entry yet, so a state that pulled before this change (or a caller
|
|
567
|
+
* that seeded `documentPullDiagnostics` directly) still inherits correctly. */
|
|
568
|
+
function pullSourceDiagnostics(state, normalizedPath, identifier) {
|
|
569
|
+
const sources = state.documentPullDiagnosticsBySource?.get(normalizedPath);
|
|
570
|
+
const stored = sources?.get(identifier ?? BARE_PULL_SOURCE);
|
|
571
|
+
if (stored)
|
|
572
|
+
return stored;
|
|
573
|
+
return sources && sources.size > 0
|
|
574
|
+
? []
|
|
575
|
+
: (state.documentPullDiagnostics.get(normalizedPath) ?? []);
|
|
576
|
+
}
|
|
577
|
+
/** Store one source's findings and republish the union to
|
|
578
|
+
* `documentPullDiagnostics`, which every reader outside this file uses. */
|
|
579
|
+
function storePullSourceDiagnostics(state, normalizedPath, identifier, diagnostics) {
|
|
580
|
+
const sources = pullSourcesFor(state, normalizedPath);
|
|
581
|
+
sources.set(identifier ?? BARE_PULL_SOURCE, diagnostics);
|
|
582
|
+
state.documentPullDiagnostics.set(normalizedPath, mergeDiagnosticLists(...sources.values()));
|
|
583
|
+
}
|
|
584
|
+
/** #1667: the pull generation for a path. The fan-out deliberately lets losing
|
|
585
|
+
* pulls run to completion so their findings still reach the cache; a resync
|
|
586
|
+
* that lands first must win, so every late write re-checks this. */
|
|
587
|
+
function pullGenerationFor(state, normalizedPath) {
|
|
588
|
+
return state.pullGenerations?.get(normalizedPath) ?? 0;
|
|
589
|
+
}
|
|
590
|
+
function bumpPullGeneration(state, normalizedPath) {
|
|
591
|
+
if (!state.pullGenerations)
|
|
592
|
+
state.pullGenerations = new Map();
|
|
593
|
+
state.pullGenerations.set(normalizedPath, (state.pullGenerations.get(normalizedPath) ?? 0) + 1);
|
|
594
|
+
}
|
|
595
|
+
/** #1667: claim the newest-request slot for a source and return the claim.
|
|
596
|
+
* Called at REQUEST time; `isNewestPullRequest` re-checks it at WRITE time so
|
|
597
|
+
* a slow answer from an earlier fan-out cannot overwrite a newer one's. */
|
|
598
|
+
function claimPullRequestSequence(state, sourceKey) {
|
|
599
|
+
if (!state.pullRequestSequences)
|
|
600
|
+
state.pullRequestSequences = new Map();
|
|
601
|
+
const next = (state.pullRequestSequences.get(sourceKey) ?? 0) + 1;
|
|
602
|
+
state.pullRequestSequences.set(sourceKey, next);
|
|
603
|
+
return next;
|
|
604
|
+
}
|
|
605
|
+
function isNewestPullRequest(state, sourceKey, sequence) {
|
|
606
|
+
return (state.pullRequestSequences?.get(sourceKey) ?? 0) === sequence;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* #1667: drop every trace of a diagnostic source the server just unregistered.
|
|
610
|
+
*
|
|
611
|
+
* `client/unregisterCapability` retires a source, but its findings sit in
|
|
612
|
+
* `documentPullDiagnosticsBySource` and its resultId in `pullResultIds`, so
|
|
613
|
+
* without this the union keeps serving a retired source's diagnostics until
|
|
614
|
+
* something resyncs the file. Roslyn and vtsls re-register their sources on
|
|
615
|
+
* solution reload, which is exactly when the old source's findings are stale.
|
|
616
|
+
*
|
|
617
|
+
* Purges the source's slice from every path and republishes each affected
|
|
618
|
+
* path's union, so the removal is visible on the next read rather than only
|
|
619
|
+
* after the next pull.
|
|
620
|
+
*/
|
|
621
|
+
function retirePullSource(state, identifier) {
|
|
622
|
+
const suffix = `${PULL_SOURCE_KEY_SEPARATOR}${identifier}`;
|
|
623
|
+
for (const source of [state.pullResultIds, state.pullRequestSequences]) {
|
|
624
|
+
if (!source)
|
|
625
|
+
continue;
|
|
626
|
+
for (const key of [...source.keys()]) {
|
|
627
|
+
if (key.endsWith(suffix))
|
|
628
|
+
source.delete(key);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
for (const [normalizedPath, sources,] of state.documentPullDiagnosticsBySource ?? []) {
|
|
632
|
+
if (!sources.delete(identifier))
|
|
633
|
+
continue;
|
|
634
|
+
state.documentPullDiagnostics.set(normalizedPath, mergeDiagnosticLists(...sources.values()));
|
|
635
|
+
bumpDiagnosticsVersion(state, normalizedPath);
|
|
636
|
+
state.diagnosticEmitter.emit("diagnostics", normalizedPath);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* #1667: the diagnostic sources to pull for a document - always the bare
|
|
641
|
+
* request (a server may answer it even while registering named sources, and it
|
|
642
|
+
* is the ONLY request a single-source server understands), plus one per
|
|
643
|
+
* distinct `identifier` on a live `textDocument/diagnostic` registration.
|
|
644
|
+
*
|
|
645
|
+
* Derived from `dynamicRegistrations` on every call rather than cached in a
|
|
646
|
+
* parallel list: the registration map is the single source of truth, and
|
|
647
|
+
* `client/unregisterCapability` must be able to retire a source immediately.
|
|
648
|
+
*/
|
|
649
|
+
export function pullDiagnosticSources(state) {
|
|
650
|
+
const identifiers = new Set();
|
|
651
|
+
for (const registration of state.dynamicRegistrations?.values() ?? []) {
|
|
652
|
+
if (registration.method !== "textDocument/diagnostic")
|
|
653
|
+
continue;
|
|
654
|
+
if (registration.identifier)
|
|
655
|
+
identifiers.add(registration.identifier);
|
|
656
|
+
}
|
|
657
|
+
// Sorted for a stable, reproducible request order in logs and tests; the
|
|
658
|
+
// fan-out is parallel, so order carries no priority.
|
|
659
|
+
return [undefined, ...[...identifiers].sort((a, b) => a.localeCompare(b))];
|
|
660
|
+
}
|
|
661
|
+
/** #1531: bump the client-global diagnostics counter and stamp the path it was
|
|
662
|
+
* bumped FOR. The single seam every "fresh diagnostics stored" site goes
|
|
663
|
+
* through, so the per-path stamp can never drift from the global counter. */
|
|
664
|
+
export function bumpDiagnosticsVersion(state, normalizedPath) {
|
|
665
|
+
state.diagnosticsVersion += 1;
|
|
666
|
+
state.diagnosticsVersionsByPath?.set(normalizedPath, state.diagnosticsVersion);
|
|
667
|
+
}
|
|
668
|
+
/** #1531: the global counter's value when diagnostics were last stored for
|
|
669
|
+
* `normalizedPath`, or 0 when nothing is stored. 0 is a safe floor because the
|
|
670
|
+
* counter starts at 0 and only ever increases, so any later publication for the
|
|
671
|
+
* path compares greater than a baseline read while it was absent. */
|
|
672
|
+
export function diagnosticsVersionForPath(state, normalizedPath) {
|
|
673
|
+
return state.diagnosticsVersionsByPath?.get(normalizedPath) ?? 0;
|
|
674
|
+
}
|
|
675
|
+
/** Exported for tests: the quiet-window timer cancel on clear/resync is the
|
|
676
|
+
* headline #1412 safety property (a stale versionless publication must never
|
|
677
|
+
* land after the document content changed). */
|
|
678
|
+
export function clearDiagnosticsForPath(state, normalizedPath) {
|
|
679
|
+
// SAFETY: same pre-split field names as `getMergedDiagnosticsForPath`.
|
|
680
|
+
// Clearing must cover the legacy maps too, or a state that still carries
|
|
681
|
+
// them replays a stale diagnostic after the clear. Both are declared
|
|
682
|
+
// OPTIONAL, so every use below is `?.`-guarded on a state that lacks them.
|
|
438
683
|
const legacy = state;
|
|
439
684
|
state.pushDiagnostics?.delete(normalizedPath);
|
|
685
|
+
const pending = state.pendingDiagnostics?.get(normalizedPath);
|
|
686
|
+
if (pending)
|
|
687
|
+
clearTimeout(pending);
|
|
688
|
+
state.pendingDiagnostics?.delete(normalizedPath);
|
|
440
689
|
state.pushDiagnosticTimestamps?.delete(normalizedPath);
|
|
441
690
|
state.documentPullDiagnostics?.delete(normalizedPath);
|
|
442
691
|
state.documentPullDiagnosticTimestamps?.delete(normalizedPath);
|
|
@@ -446,14 +695,99 @@ function clearDiagnosticsForPath(state, normalizedPath) {
|
|
|
446
695
|
// `documentContentHashes` record is intentionally retained: it describes what
|
|
447
696
|
// we sent, which the NEXT publish for that version still needs to bind to.)
|
|
448
697
|
state.diagnosticBindings?.delete(normalizedPath);
|
|
698
|
+
// #1531: the per-path publication stamp describes diagnostics that no longer
|
|
699
|
+
// exist — drop it with them. Safe because the stamps hold the GLOBAL counter's
|
|
700
|
+
// value: a baseline captured before this clear still compares less than any
|
|
701
|
+
// later publication for the path, so dropping the entry cannot manufacture a
|
|
702
|
+
// missed answer.
|
|
703
|
+
state.diagnosticsVersionsByPath?.delete(normalizedPath);
|
|
449
704
|
// #1104: a resync invalidates any `unchanged`-report basis too — the next
|
|
450
705
|
// pull must not inherit a resultId/contentHash computed against the
|
|
451
706
|
// content this resync just replaced.
|
|
452
|
-
|
|
707
|
+
// #1667: every SOURCE's basis, not just the bare one - a multi-source server
|
|
708
|
+
// holds one resultId per identifier, and leaving even one behind lets the
|
|
709
|
+
// next pull inherit against content this resync replaced.
|
|
710
|
+
for (const key of pullSourceKeysForPath(state, normalizedPath)) {
|
|
711
|
+
state.pullResultIds?.delete(key);
|
|
712
|
+
}
|
|
713
|
+
// `pullRequestSequences` is deliberately NOT cleared here. It is a
|
|
714
|
+
// monotonic per-source counter, and resetting it would let a request issued
|
|
715
|
+
// BEFORE this resync claim the same number as one issued after, so the stale
|
|
716
|
+
// answer would pass the newest-request check. The two guards cover different
|
|
717
|
+
// cases and neither subsumes the other: the generation bump below invalidates
|
|
718
|
+
// in-flight work when nothing newer was requested, and the sequence stamp
|
|
719
|
+
// invalidates it when something newer was.
|
|
720
|
+
state.documentPullDiagnosticsBySource?.delete(normalizedPath);
|
|
721
|
+
// #1667: invalidate any pull still in flight for this path. The fan-out lets
|
|
722
|
+
// losing pulls run to completion so their findings still reach the cache;
|
|
723
|
+
// without this bump one of them could resurrect what this resync cleared.
|
|
724
|
+
bumpPullGeneration(state, normalizedPath);
|
|
453
725
|
state.workspacePullResultCache?.delete(normalizedPath);
|
|
454
726
|
legacy.diagnostics?.delete(normalizedPath);
|
|
455
727
|
legacy.diagnosticTimestamps?.delete(normalizedPath);
|
|
456
728
|
}
|
|
729
|
+
/**
|
|
730
|
+
* #1639: `durationMs` measures the PULL SETTLE ITSELF — the caller passes how
|
|
731
|
+
* long `clientRequestPullDiagnostics` (plus any retries) took to resolve
|
|
732
|
+
* `found`/`clean` — never time-since-didOpen. Document age is a genuinely
|
|
733
|
+
* useful signal but a DIFFERENT one; it stays under its own honest name in
|
|
734
|
+
* `metadata.elapsedSinceDidOpenMs`, exactly like the push-path `logSequence`
|
|
735
|
+
* above already does. Before this fix the top-level field carried the same
|
|
736
|
+
* age value pull requests use for retry-budget bookkeeping, so a session's
|
|
737
|
+
* latency percentiles for this phase read multi-second document lifetimes as
|
|
738
|
+
* millisecond operation costs (147/239 records read >60s "durations" for
|
|
739
|
+
* settles that took milliseconds).
|
|
740
|
+
*
|
|
741
|
+
* `version` reads the real tracked version from `diagnosticsVersionsByPath`
|
|
742
|
+
* (bumped by `bumpDiagnosticsVersion` right after this pull's diagnostics
|
|
743
|
+
* were stored — see the `documentPullDiagnostics.set` call above it) instead
|
|
744
|
+
* of a hardcoded `null`, which made every pull settle look "unbound" to
|
|
745
|
+
* staleness forensics even though this codebase already tracks a real
|
|
746
|
+
* version for the path. Falls back to the explicit `"pull-unversioned"`
|
|
747
|
+
* marker only when nothing has been stored yet, so "no data" is never
|
|
748
|
+
* confused with "confirmed no version" (`null`).
|
|
749
|
+
*
|
|
750
|
+
* `pullSettleSource` distinguishes the two legitimate call paths that can
|
|
751
|
+
* both settle a pull for the same file close together: a real
|
|
752
|
+
* content-collecting touch ("pull") and `ensureWarmForSweep`'s warm-up-only
|
|
753
|
+
* touch ("pull-warmup"), which runs a real round trip purely to prove the
|
|
754
|
+
* server answers before the sweep's real touch immediately follows it.
|
|
755
|
+
* Before this fix both logged identically as settleSource "pull", so a
|
|
756
|
+
* session with warm-up sweeps double-counted: 239 records against 145
|
|
757
|
+
* touches, with same-file pairs ~60ms apart and near-identical document
|
|
758
|
+
* ages — the warm-up settle and the real touch's settle for the same file.
|
|
759
|
+
*/
|
|
760
|
+
function logTypeScriptPullSettle(state, normalizedPath, durationMs, pullSettleSource) {
|
|
761
|
+
if (state.serverId !== "typescript")
|
|
762
|
+
return;
|
|
763
|
+
const diagnostics = state.documentPullDiagnostics.get(normalizedPath) ?? [];
|
|
764
|
+
const elapsedSinceDidOpenMs = Math.max(0, Date.now() - (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
|
|
765
|
+
const diagnosticCodes = [
|
|
766
|
+
...new Set(diagnostics
|
|
767
|
+
.map((diagnostic) => diagnostic.code)
|
|
768
|
+
.filter((code) => code !== undefined)
|
|
769
|
+
.map(String)),
|
|
770
|
+
].slice(0, 8);
|
|
771
|
+
const trackedVersion = state.diagnosticsVersionsByPath?.get(normalizedPath);
|
|
772
|
+
logLatency({
|
|
773
|
+
type: "phase",
|
|
774
|
+
phase: "lsp_typescript_diagnostic_sequence",
|
|
775
|
+
filePath: normalizedPath,
|
|
776
|
+
durationMs,
|
|
777
|
+
metadata: {
|
|
778
|
+
serverId: state.serverId,
|
|
779
|
+
outcome: "settled",
|
|
780
|
+
launchVariant: state.launchVariant ?? "unknown",
|
|
781
|
+
publicationIndex: state.diagnosticPublicationCounts.get(normalizedPath) ?? 0,
|
|
782
|
+
version: trackedVersion ?? "pull-unversioned",
|
|
783
|
+
diagnosticCount: diagnostics.length,
|
|
784
|
+
diagnosticCodes,
|
|
785
|
+
elapsedSinceDidOpenMs,
|
|
786
|
+
settledReturn: true,
|
|
787
|
+
settleSource: pullSettleSource,
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
}
|
|
457
791
|
/**
|
|
458
792
|
* #1095: fingerprint the EXACT didOpen/didChange payload text at SEND time and
|
|
459
793
|
* tag it with the document version it was sent as, so a later
|
|
@@ -466,8 +800,94 @@ function recordSentContent(state, normalizedPath, version, content) {
|
|
|
466
800
|
state.documentContentHashes.set(normalizedPath, {
|
|
467
801
|
version,
|
|
468
802
|
hash: hashDiagnosticContent(content),
|
|
803
|
+
// #1669: retain the full text only for Incremental — the sole reader
|
|
804
|
+
// (`buildContentChanges`) needs it to compute the NEXT change against
|
|
805
|
+
// what the server last saw; Full/None never read this field.
|
|
806
|
+
...(state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL && {
|
|
807
|
+
text: content,
|
|
808
|
+
}),
|
|
809
|
+
});
|
|
810
|
+
// #1641 criterion 3: the in-memory document's version + content length AT
|
|
811
|
+
// SEND TIME, so a later "diagnostic cited a line past current disk EOF"
|
|
812
|
+
// record (`diagnostic_past_eof`, clients/diagnostic-line-freshness.ts) can be
|
|
813
|
+
// paired with the send that produced the divergent in-memory document —
|
|
814
|
+
// today only the symptom (the stale citation) is observable; this is the
|
|
815
|
+
// cause side of the same timeline.
|
|
816
|
+
//
|
|
817
|
+
// Review round F4/F1: `contentLineCount` MUST use the same LSP-addressable
|
|
818
|
+
// convention as the gate itself (newline count + 1 — a trailing `\n` adds
|
|
819
|
+
// one more, empty, addressable line; an empty document is still 1 line),
|
|
820
|
+
// or the two records disagree by one at exactly the boundary this counter
|
|
821
|
+
// exists to help debug. Counted directly (no `.split("\n")`, which
|
|
822
|
+
// allocates one substring per line — measured at 13.3ms/200k allocations
|
|
823
|
+
// on a 7MB send) since only the COUNT is needed here, not the lines.
|
|
824
|
+
let newlineCount = 0;
|
|
825
|
+
for (let i = 0; i < content.length; i++) {
|
|
826
|
+
if (content.charCodeAt(i) === 10)
|
|
827
|
+
newlineCount++;
|
|
828
|
+
}
|
|
829
|
+
logLatency({
|
|
830
|
+
type: "phase",
|
|
831
|
+
phase: "lsp_document_send",
|
|
832
|
+
filePath: normalizedPath,
|
|
833
|
+
durationMs: 0,
|
|
834
|
+
metadata: {
|
|
835
|
+
version,
|
|
836
|
+
contentLength: content.length,
|
|
837
|
+
contentLineCount: newlineCount + 1,
|
|
838
|
+
},
|
|
469
839
|
});
|
|
470
840
|
}
|
|
841
|
+
/**
|
|
842
|
+
* #1669: the `contentChanges` array for a `textDocument/didChange` notification.
|
|
843
|
+
*
|
|
844
|
+
* Full/None (or unrecognized/absent) sync kind: unchanged — a single
|
|
845
|
+
* whole-document `{ text }` event, the shape pi-lens has always sent.
|
|
846
|
+
*
|
|
847
|
+
* Incremental: a server registering Incremental-only expects every change
|
|
848
|
+
* event to carry a `range`; a shapeless whole-document event is out of spec
|
|
849
|
+
* for it. Rather than hand-roll a real diff, send the SAFEST incremental
|
|
850
|
+
* representation of a full update — one ranged edit spanning the entire
|
|
851
|
+
* PREVIOUS document (start of file to its last character), replacing it with
|
|
852
|
+
* the entire new content. That is spec-valid for any Incremental server and
|
|
853
|
+
* semantically identical to the Full-sync event it replaces.
|
|
854
|
+
*
|
|
855
|
+
* The previous document's end position is computed from the text
|
|
856
|
+
* `recordSentContent` retained for THIS path the last time content was sent
|
|
857
|
+
* (reusing that seam rather than a second parallel content store — see its
|
|
858
|
+
* doc comment). No prior text on record (first change since Incremental was
|
|
859
|
+
* negotiated, or the path was never sent before) falls back to the
|
|
860
|
+
* whole-document shape — still spec-valid, and self-heals once the next
|
|
861
|
+
* change has a retained previous text to diff against.
|
|
862
|
+
*/
|
|
863
|
+
function buildContentChanges(state, normalizedPath, content) {
|
|
864
|
+
if (state.syncKind !== TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
|
|
865
|
+
return [{ text: content }];
|
|
866
|
+
}
|
|
867
|
+
const previousText = state.documentContentHashes.get(normalizedPath)?.text;
|
|
868
|
+
if (previousText === undefined) {
|
|
869
|
+
return [{ text: content }];
|
|
870
|
+
}
|
|
871
|
+
// #1669 review F6: split on every LSP line terminator (\r\n, lone \r, or
|
|
872
|
+
// \n), not just \n — a document using CRLF or lone-CR line endings would
|
|
873
|
+
// otherwise be undercounted, computing a range that ends mid-document
|
|
874
|
+
// instead of at the real last line.
|
|
875
|
+
const previousLines = previousText.split(/\r\n|\r|\n/);
|
|
876
|
+
const lastLine = previousLines.length - 1;
|
|
877
|
+
const lastLineText = previousLines[lastLine] ?? "";
|
|
878
|
+
return [
|
|
879
|
+
{
|
|
880
|
+
range: {
|
|
881
|
+
start: { line: 0, character: 0 },
|
|
882
|
+
end: {
|
|
883
|
+
line: lastLine,
|
|
884
|
+
character: convertCharacterOffset(state.positionEncoding, lastLineText, lastLineText.length),
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
text: content,
|
|
888
|
+
},
|
|
889
|
+
];
|
|
890
|
+
}
|
|
471
891
|
// Methods that can be registered dynamically and map to operationSupport keys
|
|
472
892
|
const DYNAMIC_OPERATION_METHOD_MAP = {
|
|
473
893
|
"textDocument/definition": "definition",
|
|
@@ -484,14 +904,23 @@ const DYNAMIC_OPERATION_METHOD_MAP = {
|
|
|
484
904
|
"textDocument/prepareCallHierarchy": "callHierarchy",
|
|
485
905
|
};
|
|
486
906
|
export function applyDynamicCapabilities(state) {
|
|
487
|
-
const
|
|
907
|
+
const registrations = [...state.dynamicRegistrations.values()];
|
|
908
|
+
const registeredMethods = new Set(registrations.map((r) => r.method));
|
|
488
909
|
const hasDynamicPull = registeredMethods.has("textDocument/diagnostic") ||
|
|
489
910
|
registeredMethods.has("workspace/diagnostic");
|
|
490
911
|
if (hasDynamicPull) {
|
|
491
912
|
state.workspaceDiagnosticsSupport = {
|
|
492
913
|
advertised: true,
|
|
493
914
|
mode: "pull",
|
|
494
|
-
|
|
915
|
+
// #1667: workspace-pull support is what the REGISTRATION declares. The
|
|
916
|
+
// spec registers workspace pull as `registerOptions.workspaceDiagnostics`
|
|
917
|
+
// on a `textDocument/diagnostic` registration - `workspace/diagnostic` is
|
|
918
|
+
// not itself a registrable method - so reading the method name alone
|
|
919
|
+
// missed every conforming server and this client never issued a
|
|
920
|
+
// workspace pull for one. The method-name check stays as a fallback for
|
|
921
|
+
// servers that register it as a method anyway.
|
|
922
|
+
workspaceDiagnostics: registrations.some((r) => r.workspaceDiagnostics === true) ||
|
|
923
|
+
registeredMethods.has("workspace/diagnostic"),
|
|
495
924
|
diagnosticProviderKind: "dynamic",
|
|
496
925
|
};
|
|
497
926
|
}
|
|
@@ -511,15 +940,6 @@ export function applyDynamicCapabilities(state) {
|
|
|
511
940
|
}
|
|
512
941
|
}
|
|
513
942
|
}
|
|
514
|
-
/**
|
|
515
|
-
* Resolve a `workspace/configuration` request item's `section` (a dot-path,
|
|
516
|
-
* e.g. "scan.jobs") against the server's `initializationOptions` blob.
|
|
517
|
-
* - No section (undefined/empty) → the whole blob, per spec ("if a scope
|
|
518
|
-
* isn't asked for" the client returns the full settings for that scope).
|
|
519
|
-
* - An unresolvable path → `null`, never the whole blob — a server asking
|
|
520
|
-
* for a section it doesn't get must not silently receive unrelated config.
|
|
521
|
-
* Exported for the #983 regression test.
|
|
522
|
-
*/
|
|
523
943
|
export function resolveConfigurationSection(initialization, section) {
|
|
524
944
|
if (!initialization)
|
|
525
945
|
return section ? null : {};
|
|
@@ -527,9 +947,7 @@ export function resolveConfigurationSection(initialization, section) {
|
|
|
527
947
|
return initialization;
|
|
528
948
|
let cur = initialization;
|
|
529
949
|
for (const part of section.split(".")) {
|
|
530
|
-
if (typeof cur !== "object" ||
|
|
531
|
-
cur === null ||
|
|
532
|
-
!Object.prototype.hasOwnProperty.call(cur, part)) {
|
|
950
|
+
if (typeof cur !== "object" || cur === null || !Object.hasOwn(cur, part)) {
|
|
533
951
|
return null;
|
|
534
952
|
}
|
|
535
953
|
cur = cur[part];
|
|
@@ -541,6 +959,12 @@ export function resolveConfigurationSection(initialization, section) {
|
|
|
541
959
|
// a real language server. Not part of the public client API surface.
|
|
542
960
|
export function setupIncomingHandlers(state, initialization) {
|
|
543
961
|
state.connection.onNotification("textDocument/publishDiagnostics", (params) => {
|
|
962
|
+
// #1639: the settle operation's own clock for THIS publish. A
|
|
963
|
+
// "quiet-window" settle's durationMs below measures from here (when
|
|
964
|
+
// this publish arrived and — armed or re-armed — the debounce timer)
|
|
965
|
+
// to when that timer actually fires, i.e. the real debounce wait, not
|
|
966
|
+
// time-since-didOpen.
|
|
967
|
+
const publishReceivedAt = Date.now();
|
|
544
968
|
const filePath = uriToPath(params.uri);
|
|
545
969
|
const normalizedPath = normalizeMapKey(filePath);
|
|
546
970
|
// A server can flush a queued publish after didClose during teardown.
|
|
@@ -558,7 +982,73 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
558
982
|
message: `server=${state.serverId} pubVersion=${docVersion} docVersion=${state.documentVersions?.get(normalizedPath)} diags=${newDiags.length}`,
|
|
559
983
|
});
|
|
560
984
|
}
|
|
561
|
-
const strategy = getStrategy(state.serverId);
|
|
985
|
+
const strategy = getStrategy(state.serverId, state.launchVariant);
|
|
986
|
+
// Publication counting and code extraction exist only for the
|
|
987
|
+
// TypeScript diagnostic-sequence telemetry; skip the bookkeeping for
|
|
988
|
+
// every other push server on this hot receive path.
|
|
989
|
+
const isTypeScriptTelemetry = state.serverId === "typescript";
|
|
990
|
+
const publicationIndex = isTypeScriptTelemetry
|
|
991
|
+
? (state.diagnosticPublicationCounts.get(normalizedPath) ?? 0) + 1
|
|
992
|
+
: 0;
|
|
993
|
+
if (isTypeScriptTelemetry) {
|
|
994
|
+
state.diagnosticPublicationCounts.set(normalizedPath, publicationIndex);
|
|
995
|
+
}
|
|
996
|
+
const diagnosticCodes = isTypeScriptTelemetry
|
|
997
|
+
? [
|
|
998
|
+
...new Set(newDiags
|
|
999
|
+
.map((diagnostic) => diagnostic.code)
|
|
1000
|
+
.filter((code) => code !== undefined)
|
|
1001
|
+
.map(String)),
|
|
1002
|
+
].slice(0, 8)
|
|
1003
|
+
: [];
|
|
1004
|
+
// #1639: `durationMs` measures the settle operation, never
|
|
1005
|
+
// time-since-didOpen — document age keeps its own honest name in
|
|
1006
|
+
// `metadata.elapsedSinceDidOpenMs`, exactly like the pull-path
|
|
1007
|
+
// producer above. Before this fix EVERY call (settled or not) logged
|
|
1008
|
+
// the doc-age value as durationMs, which is what actually produced
|
|
1009
|
+
// the issue's cited evidence: this push-path producer, not the pull
|
|
1010
|
+
// path, emitted all 239 records (147 of them >60s "durations" for
|
|
1011
|
+
// settles that took milliseconds; a 61ms same-file pair is this
|
|
1012
|
+
// function's OWN unsettled-then-settled shape — the
|
|
1013
|
+
// `logSequence(false)` raw-receipt record immediately followed by
|
|
1014
|
+
// the `logSequence(true, ...)` settle record for the same publish).
|
|
1015
|
+
// - "first-push": settles immediately — the seed-first-push strategy
|
|
1016
|
+
// bypasses the debounce timer entirely, so there is no wait to
|
|
1017
|
+
// measure; durationMs is 0.
|
|
1018
|
+
// - "quiet-window": the real debounce wait, timed from THIS publish's
|
|
1019
|
+
// own receipt (a later publish that re-arms the timer captures its
|
|
1020
|
+
// OWN `publishReceivedAt`, so a re-armed wait is timed from the
|
|
1021
|
+
// push that actually won the debounce, not the first one that lost
|
|
1022
|
+
// it).
|
|
1023
|
+
// - "publication": a raw per-publication receipt, never itself a
|
|
1024
|
+
// settle — durationMs is 0 and `settledReturn` stays false. This
|
|
1025
|
+
// is the record that used to log with NO settleSource at all;
|
|
1026
|
+
// giving it one closes AC3 (distinguish, don't silently overload,
|
|
1027
|
+
// the phase's two shapes) for the pairs that actually occur.
|
|
1028
|
+
const logSequence = (settledReturn, settleSource, durationMs) => {
|
|
1029
|
+
if (state.serverId !== "typescript")
|
|
1030
|
+
return;
|
|
1031
|
+
const elapsedSinceDidOpenMs = Math.max(0, Date.now() -
|
|
1032
|
+
(state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
|
|
1033
|
+
logLatency({
|
|
1034
|
+
type: "phase",
|
|
1035
|
+
phase: "lsp_typescript_diagnostic_sequence",
|
|
1036
|
+
filePath: normalizedPath,
|
|
1037
|
+
durationMs,
|
|
1038
|
+
metadata: {
|
|
1039
|
+
serverId: state.serverId,
|
|
1040
|
+
outcome: settledReturn ? "settled" : "published",
|
|
1041
|
+
launchVariant: state.launchVariant ?? "unknown",
|
|
1042
|
+
publicationIndex,
|
|
1043
|
+
version: docVersion ?? "push-unversioned",
|
|
1044
|
+
diagnosticCount: newDiags.length,
|
|
1045
|
+
diagnosticCodes,
|
|
1046
|
+
elapsedSinceDidOpenMs,
|
|
1047
|
+
settledReturn,
|
|
1048
|
+
settleSource,
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
};
|
|
562
1052
|
// Record the document version these diagnostics were computed against
|
|
563
1053
|
// (when the server reports it) so waitForDiagnostics can reject results
|
|
564
1054
|
// that lag behind the latest didChange instead of serving them as fresh.
|
|
@@ -603,10 +1093,12 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
603
1093
|
// Known, deliberately out-of-scope gaps: the pull-diagnostics path
|
|
604
1094
|
// (clientRequestPullDiagnostics/clientRequestWorkspaceDiagnostics) has no
|
|
605
1095
|
// version stamp to compare against in this codebase's current handling,
|
|
606
|
-
// so nothing analogous is applied there.
|
|
607
|
-
// single global counter
|
|
608
|
-
// fresh push
|
|
609
|
-
//
|
|
1096
|
+
// so nothing analogous is applied there. The other gap this note used to
|
|
1097
|
+
// record — `diagnosticsVersion` being a single global counter, so an
|
|
1098
|
+
// unrelated path's fresh push satisfied a wait for THIS path — is closed by
|
|
1099
|
+
// #1531: every bump also stamps `diagnosticsVersionsByPath`, and both the
|
|
1100
|
+
// `minVersion` freshness gate and the auxiliary answered/silent evidence
|
|
1101
|
+
// check read that per-path stamp.
|
|
610
1102
|
const isSupersededPush = () => {
|
|
611
1103
|
if (docVersion === undefined)
|
|
612
1104
|
return false;
|
|
@@ -622,10 +1114,14 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
622
1114
|
state.pushDiagnostics.set(normalizedPath, newDiags);
|
|
623
1115
|
state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
|
|
624
1116
|
recordDocVersion();
|
|
625
|
-
state
|
|
1117
|
+
bumpDiagnosticsVersion(state, normalizedPath);
|
|
626
1118
|
state.diagnosticEmitter.emit("diagnostics", normalizedPath);
|
|
1119
|
+
// Immediate settle — no debounce wait to measure.
|
|
1120
|
+
logSequence(true, "first-push", 0);
|
|
627
1121
|
return;
|
|
628
1122
|
}
|
|
1123
|
+
// A raw per-publication receipt, not itself a settle.
|
|
1124
|
+
logSequence(false, "publication", 0);
|
|
629
1125
|
const existingTimer = state.pendingDiagnostics.get(normalizedPath);
|
|
630
1126
|
if (existingTimer)
|
|
631
1127
|
clearTimeout(existingTimer);
|
|
@@ -636,8 +1132,10 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
636
1132
|
state.pushDiagnostics.set(normalizedPath, newDiags);
|
|
637
1133
|
state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
|
|
638
1134
|
recordDocVersion();
|
|
639
|
-
state
|
|
1135
|
+
bumpDiagnosticsVersion(state, normalizedPath);
|
|
640
1136
|
state.diagnosticEmitter.emit("diagnostics", normalizedPath);
|
|
1137
|
+
// The real debounce wait, timed from THIS publish's own receipt.
|
|
1138
|
+
logSequence(true, "quiet-window", Date.now() - publishReceivedAt);
|
|
641
1139
|
}, strategy.debounceMs);
|
|
642
1140
|
state.pendingDiagnostics.set(normalizedPath, timer);
|
|
643
1141
|
});
|
|
@@ -646,25 +1144,57 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
646
1144
|
]);
|
|
647
1145
|
state.connection.onRequest("client/registerCapability", async (params) => {
|
|
648
1146
|
for (const reg of params?.registrations ?? []) {
|
|
1147
|
+
const options = reg.registerOptions;
|
|
1148
|
+
const commands = Array.isArray(options?.commands)
|
|
1149
|
+
? options.commands.filter((cmd) => typeof cmd === "string")
|
|
1150
|
+
: undefined;
|
|
649
1151
|
if (reg.id && reg.method) {
|
|
650
|
-
|
|
1152
|
+
// #1667: keep the whole registration, not just its method. An empty
|
|
1153
|
+
// `identifier` is treated as absent - it names no source, and
|
|
1154
|
+
// sending `identifier: ""` would be a second, indistinguishable
|
|
1155
|
+
// bare pull.
|
|
1156
|
+
state.dynamicRegistrations.set(reg.id, {
|
|
1157
|
+
method: reg.method,
|
|
1158
|
+
...(typeof options?.identifier === "string" && options.identifier
|
|
1159
|
+
? { identifier: options.identifier }
|
|
1160
|
+
: {}),
|
|
1161
|
+
...(typeof options?.workspaceDiagnostics === "boolean"
|
|
1162
|
+
? { workspaceDiagnostics: options.workspaceDiagnostics }
|
|
1163
|
+
: {}),
|
|
1164
|
+
...(commands ? { commands } : {}),
|
|
1165
|
+
});
|
|
651
1166
|
}
|
|
652
1167
|
// executeCommand commands can arrive dynamically too — merge them
|
|
653
1168
|
// into the allowlist so dynamically-registered commands are runnable.
|
|
654
|
-
if (reg.method === "workspace/executeCommand" &&
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
if (typeof cmd === "string")
|
|
658
|
-
state.advertisedCommands.add(cmd);
|
|
659
|
-
}
|
|
1169
|
+
if (reg.method === "workspace/executeCommand" && commands) {
|
|
1170
|
+
for (const cmd of commands)
|
|
1171
|
+
state.advertisedCommands.add(cmd);
|
|
660
1172
|
}
|
|
661
1173
|
}
|
|
662
1174
|
applyDynamicCapabilities(state);
|
|
663
1175
|
});
|
|
664
1176
|
state.connection.onRequest("client/unregisterCapability", async (params) => {
|
|
1177
|
+
const retiredIdentifiers = new Set();
|
|
665
1178
|
for (const unreg of params?.unregisterations ?? []) {
|
|
666
|
-
if (unreg.id)
|
|
667
|
-
|
|
1179
|
+
if (!unreg.id)
|
|
1180
|
+
continue;
|
|
1181
|
+
const registration = state.dynamicRegistrations.get(unreg.id);
|
|
1182
|
+
state.dynamicRegistrations.delete(unreg.id);
|
|
1183
|
+
if (registration?.method === "textDocument/diagnostic" &&
|
|
1184
|
+
registration.identifier) {
|
|
1185
|
+
retiredIdentifiers.add(registration.identifier);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
// #1667: a retired source's cached findings and resultId must go with
|
|
1189
|
+
// it, or the union keeps serving them. Checked after the deletes above,
|
|
1190
|
+
// and only when NO surviving registration still speaks for that
|
|
1191
|
+
// identifier.
|
|
1192
|
+
const stillRegistered = new Set([...state.dynamicRegistrations.values()]
|
|
1193
|
+
.filter((r) => r.method === "textDocument/diagnostic")
|
|
1194
|
+
.map((r) => r.identifier));
|
|
1195
|
+
for (const identifier of retiredIdentifiers) {
|
|
1196
|
+
if (!stillRegistered.has(identifier)) {
|
|
1197
|
+
retirePullSource(state, identifier);
|
|
668
1198
|
}
|
|
669
1199
|
}
|
|
670
1200
|
applyDynamicCapabilities(state);
|
|
@@ -713,6 +1243,128 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
713
1243
|
return items.map((item) => resolveConfigurationSection(initialization, item?.section));
|
|
714
1244
|
});
|
|
715
1245
|
state.connection.onRequest("window/workDoneProgress/create", async () => { });
|
|
1246
|
+
// #1669: a server can send `workspace/diagnostic/refresh` (typically after a
|
|
1247
|
+
// project-wide config change) to say every pull result it has already
|
|
1248
|
+
// reported may be stale. Left unhandled, vscode-jsonrpc replies
|
|
1249
|
+
// MethodNotFound to a server explicitly telling us its results are stale —
|
|
1250
|
+
// this is exactly the signal `workspacePullResultCache` and the persisted
|
|
1251
|
+
// workspace-diagnostics cache want (the #1461 family, server-initiated for
|
|
1252
|
+
// once). Reply null per spec and drop both so the NEXT pull recomputes
|
|
1253
|
+
// instead of replaying — or inheriting an `unchanged` basis from — results
|
|
1254
|
+
// the server just told us to distrust.
|
|
1255
|
+
// #1669 review R1: single-flight coalescing latch for the re-pull pool
|
|
1256
|
+
// below, scoped to THIS client (one `setupIncomingHandlers` call per
|
|
1257
|
+
// connection). A refresh FLOOD — a watch-mode rebuild or a `git checkout`
|
|
1258
|
+
// can legitimately fire many `workspace/diagnostic/refresh` requests in a
|
|
1259
|
+
// tight burst — used to queue one independent capped pool PER refresh:
|
|
1260
|
+
// N refreshes meant N pools running concurrently (peak concurrency N x
|
|
1261
|
+
// the per-pool cap) and N full passes over every open document (N x
|
|
1262
|
+
// redundant pulls for the SAME documents). `refreshRepullRunning` gates a
|
|
1263
|
+
// SECOND pool from ever starting while one is already in flight;
|
|
1264
|
+
// `refreshRepullRerunRequested` remembers that at least one more refresh
|
|
1265
|
+
// arrived meanwhile and coalesces it into exactly ONE trailing rerun
|
|
1266
|
+
// after the current pool finishes, using a FRESH snapshot of open
|
|
1267
|
+
// documents at rerun time (not the stale list from whenever the burst
|
|
1268
|
+
// started) — so an arbitrarily large burst still costs at most 2 passes
|
|
1269
|
+
// and never more than `REFRESH_REPULL_CONCURRENCY` pulls at once.
|
|
1270
|
+
let refreshRepullRunning = false;
|
|
1271
|
+
let refreshRepullRerunRequested = false;
|
|
1272
|
+
const runRefreshRepullPool = async () => {
|
|
1273
|
+
do {
|
|
1274
|
+
refreshRepullRerunRequested = false;
|
|
1275
|
+
const toRepull = state.workspaceDiagnosticsSupport.mode === "pull"
|
|
1276
|
+
? [...state.openDocuments]
|
|
1277
|
+
: [];
|
|
1278
|
+
if (toRepull.length > 0) {
|
|
1279
|
+
try {
|
|
1280
|
+
await mapWithConcurrency(toRepull, REFRESH_REPULL_CONCURRENCY, async (normalizedPath) => {
|
|
1281
|
+
const uri = state.openDocumentUris?.get(normalizedPath);
|
|
1282
|
+
const filePath = uri ? uriToPath(uri) : normalizedPath;
|
|
1283
|
+
await clientRequestPullDiagnostics(state, filePath);
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
catch (err) {
|
|
1287
|
+
// #1669 review N2: rejections are observed, never
|
|
1288
|
+
// `void`-swallowed — `clientRequestPullDiagnostics` itself never
|
|
1289
|
+
// throws (it returns an `unavailable` outcome), so this only
|
|
1290
|
+
// fires on a genuine programming error, but a silent throw
|
|
1291
|
+
// inside `mapWithConcurrency`'s `Promise.all` must not vanish.
|
|
1292
|
+
incrementDegradationCount({
|
|
1293
|
+
kind: "lsp-pull-unconfirmed",
|
|
1294
|
+
subject: state.serverId,
|
|
1295
|
+
reason: `refresh re-pull threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
// A refresh that arrived WHILE this pool was running gets exactly
|
|
1300
|
+
// one more pass, never a pass per refresh.
|
|
1301
|
+
} while (refreshRepullRerunRequested);
|
|
1302
|
+
refreshRepullRunning = false;
|
|
1303
|
+
};
|
|
1304
|
+
state.connection.onRequest("workspace/diagnostic/refresh", async () => {
|
|
1305
|
+
state.workspacePullResultCache.clear();
|
|
1306
|
+
// #1669 review F1: clear every on-disk sweep cache this process has
|
|
1307
|
+
// recorded a cwd for — `state.root` alone is a per-SERVER identity
|
|
1308
|
+
// marker that a monorepo/multi-root project routinely nests BELOW the
|
|
1309
|
+
// real sweep cwd, so clearing ONLY there would miss the cache file the
|
|
1310
|
+
// sweep actually reads/writes. See the doc comment on
|
|
1311
|
+
// `clearAllWorkspaceDiagnosticsCaches`.
|
|
1312
|
+
clearAllWorkspaceDiagnosticsCaches();
|
|
1313
|
+
// #1669 review N3: ALSO clear at `state.root` directly. The registry
|
|
1314
|
+
// above only knows cwds a sweep has already run under IN THIS PROCESS —
|
|
1315
|
+
// a refresh arriving before this project's first sweep this process
|
|
1316
|
+
// (e.g. right after a respawn) would otherwise clear nothing at all,
|
|
1317
|
+
// and a later sweep would load the untouched, now-genuinely-stale
|
|
1318
|
+
// on-disk cache a PRIOR process/session left behind. `state.root` is
|
|
1319
|
+
// the one cwd this handler can always reach regardless of registry
|
|
1320
|
+
// state — a cheap, durable backstop for the common single-root case
|
|
1321
|
+
// where root and sweep cwd coincide. It does NOT cover a monorepo
|
|
1322
|
+
// subproject a sweep hasn't reached yet under a DIFFERENT cwd than
|
|
1323
|
+
// `state.root` — that residual gap is tracked in #1707, not silently
|
|
1324
|
+
// claimed as solved (see `clearAllWorkspaceDiagnosticsCaches`'s doc
|
|
1325
|
+
// comment for the full shape).
|
|
1326
|
+
clearWorkspaceDiagnosticsCacheAtAndAbove(state.root);
|
|
1327
|
+
// #1669 review F3: a server-initiated "everything may be stale" signal
|
|
1328
|
+
// must drop the SAME per-document state a normal resync already drops
|
|
1329
|
+
// via `clearDiagnosticsForPath` (pullResultIds, pushDiagnostics,
|
|
1330
|
+
// documentPullDiagnostics, diagnosticBindings,
|
|
1331
|
+
// diagnosticsVersionsByPath, ...) — dropping only
|
|
1332
|
+
// `workspacePullResultCache` above left every open document's OTHER
|
|
1333
|
+
// pull state intact, so the next pull would echo a disowned
|
|
1334
|
+
// `previousResultId`, get back `kind: "unchanged"`, and re-confirm
|
|
1335
|
+
// stale diagnostics under a fresh timestamp; nothing would ever
|
|
1336
|
+
// re-pull on its own. This clear is cheap and always runs — every
|
|
1337
|
+
// refresh in a burst gets its own, unlike the re-pull pool below.
|
|
1338
|
+
for (const normalizedPath of state.openDocuments) {
|
|
1339
|
+
clearDiagnosticsForPath(state, normalizedPath);
|
|
1340
|
+
}
|
|
1341
|
+
// #1669 review N2: reply to the PROTOCOL request now — every clear
|
|
1342
|
+
// above is synchronous and already complete, so there is nothing left
|
|
1343
|
+
// this reply should wait on. The re-pull below is a background
|
|
1344
|
+
// improvement (so an open document reflects the refresh without
|
|
1345
|
+
// waiting on its next edit), never something worth delaying — or
|
|
1346
|
+
// risking — the required `null` reply over.
|
|
1347
|
+
if (state.workspaceDiagnosticsSupport.mode === "pull" &&
|
|
1348
|
+
state.openDocuments.size > 0) {
|
|
1349
|
+
if (refreshRepullRunning) {
|
|
1350
|
+
// #1669 review R1: a pool is already in flight from an earlier
|
|
1351
|
+
// refresh in this burst — coalesce instead of starting a second
|
|
1352
|
+
// one.
|
|
1353
|
+
refreshRepullRerunRequested = true;
|
|
1354
|
+
}
|
|
1355
|
+
else {
|
|
1356
|
+
refreshRepullRunning = true;
|
|
1357
|
+
// `setImmediate` defers even the SYNCHRONOUS prefix of the pool
|
|
1358
|
+
// (URI lookups, map bookkeeping) to the next tick, so it can
|
|
1359
|
+
// never compete with this reply for the current one regardless
|
|
1360
|
+
// of how many documents are open.
|
|
1361
|
+
setImmediate(() => {
|
|
1362
|
+
void runRefreshRepullPool();
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return null;
|
|
1367
|
+
});
|
|
716
1368
|
}
|
|
717
1369
|
/**
|
|
718
1370
|
* #1127: record the FIRST moment this client observed its own death. Detection
|
|
@@ -771,26 +1423,160 @@ function setupConnectionLifecycle(state, recentStderr) {
|
|
|
771
1423
|
}
|
|
772
1424
|
});
|
|
773
1425
|
}
|
|
1426
|
+
/** Margin between each source's own request timeout and the race's backstop
|
|
1427
|
+
* deadline, so the natural "all sources settled" path wins over the backstop
|
|
1428
|
+
* and the race never reports `unavailable` for sources that did answer. */
|
|
1429
|
+
const PULL_RACE_BACKSTOP_MARGIN_MS = 25;
|
|
1430
|
+
/**
|
|
1431
|
+
* #1667: pull EVERY registered diagnostic source for a file, in parallel, and
|
|
1432
|
+
* answer as soon as the first source returns findings for that file.
|
|
1433
|
+
*
|
|
1434
|
+
* Roslyn, vtsls and some Java setups register `textDocument/diagnostic` once
|
|
1435
|
+
* per source (syntax, semantic, analyzers), each named by
|
|
1436
|
+
* `registerOptions.identifier`. One bare request asks only the server's default
|
|
1437
|
+
* source, so whole categories of findings were never requested. The fan-out
|
|
1438
|
+
* asks all of them plus the bare request.
|
|
1439
|
+
*
|
|
1440
|
+
* Timing is deliberate and load-bearing:
|
|
1441
|
+
* - PARALLEL, never one source after another. Sequencing would add every
|
|
1442
|
+
* slow source's latency to every touch.
|
|
1443
|
+
* - The race resolves on the FIRST source with findings for the file, with no
|
|
1444
|
+
* settle or debounce window after the match. A post-match wait is the exact
|
|
1445
|
+
* latency shape #1112/#1407 already cost this codebase once.
|
|
1446
|
+
* - Losing sources are NOT cancelled. Each source writes its own findings into
|
|
1447
|
+
* the pull cache before it resolves, so a late answer merges in the
|
|
1448
|
+
* background and the NEXT read of the file sees it. Late answers are not
|
|
1449
|
+
* dropped, they are just not waited for.
|
|
1450
|
+
*
|
|
1451
|
+
* The race runs inside the existing `raceToCompletion` primitive rather than a
|
|
1452
|
+
* second first-wins helper.
|
|
1453
|
+
*/
|
|
774
1454
|
async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQUEST_TIMEOUT_MS) {
|
|
775
1455
|
if (!isClientAlive(state))
|
|
776
1456
|
return { status: "unavailable" };
|
|
777
|
-
const
|
|
1457
|
+
const sources = pullDiagnosticSources(state);
|
|
1458
|
+
if (sources.length === 1) {
|
|
1459
|
+
// The overwhelmingly common case: no named sources registered. Skip the
|
|
1460
|
+
// race entirely so a single-source server pays nothing for this feature.
|
|
1461
|
+
return aggregatePullOutcomes(state, filePath, [
|
|
1462
|
+
await pullDiagnosticSource(state, filePath, budgetMs, undefined),
|
|
1463
|
+
]);
|
|
1464
|
+
}
|
|
1465
|
+
const attempts = sources.map((identifier) => pullDiagnosticSource(state, filePath, budgetMs, identifier));
|
|
1466
|
+
const settled = await raceToCompletion(attempts, (results) => results.some((r) => r.status === "found" && r.primaryCount > 0), {
|
|
1467
|
+
// No graceMs: finalize the instant a source answers for this file.
|
|
1468
|
+
// The per-request `withTimeout` inside each attempt is the real bound;
|
|
1469
|
+
// this deadline is only a backstop, so it sits just past it.
|
|
1470
|
+
timeoutMs: Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)) +
|
|
1471
|
+
PULL_RACE_BACKSTOP_MARGIN_MS,
|
|
1472
|
+
});
|
|
1473
|
+
return aggregatePullOutcomes(state, filePath, settled);
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Collapse the sources that answered into one outcome for the caller.
|
|
1477
|
+
* `found` beats `clean` beats `unavailable`: a source with findings is an
|
|
1478
|
+
* answer, an authoritative empty report is an answer, and only "nobody
|
|
1479
|
+
* answered" is unavailable (#240 — an empty result must distinguish clean
|
|
1480
|
+
* from errored).
|
|
1481
|
+
*/
|
|
1482
|
+
function aggregatePullOutcomes(state, filePath, outcomes) {
|
|
1483
|
+
const found = outcomes.filter((o) => o.status === "found");
|
|
1484
|
+
if (found.length > 0) {
|
|
1485
|
+
const normalizedPath = normalizeMapKey(filePath);
|
|
1486
|
+
// The union for the requested file, which is what a caller reading the
|
|
1487
|
+
// cache will see. A source that only reported `relatedDocuments` findings
|
|
1488
|
+
// contributes nothing there, so fall back to its own count.
|
|
1489
|
+
const unionCount = state.documentPullDiagnostics.get(normalizedPath)?.length ?? 0;
|
|
1490
|
+
return {
|
|
1491
|
+
status: "found",
|
|
1492
|
+
count: Math.max(unionCount, ...found.map((o) => o.count)),
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
// #240, applied across sources: `clean` is an authoritative "this file has no
|
|
1496
|
+
// findings", and one source saying so proves nothing about a source that
|
|
1497
|
+
// FAILED to answer. A fan-out where any source errored is therefore
|
|
1498
|
+
// unavailable, not clean - the caller falls through to the push-wait/timeout
|
|
1499
|
+
// backstop instead of recording a confirmed-clean touch it cannot support.
|
|
1500
|
+
// (`raceToCompletion` drops still-pending promises from its result array, so
|
|
1501
|
+
// "every source settled, none errored" is the only shape that reads clean.)
|
|
1502
|
+
if (outcomes.some((o) => o.status === "unavailable")) {
|
|
1503
|
+
return { status: "unavailable" };
|
|
1504
|
+
}
|
|
1505
|
+
return outcomes.some((o) => o.status === "clean")
|
|
1506
|
+
? { status: "clean" }
|
|
1507
|
+
: { status: "unavailable" };
|
|
1508
|
+
}
|
|
1509
|
+
async function pullDiagnosticSource(state, filePath, budgetMs, identifier) {
|
|
1510
|
+
if (!isClientAlive(state))
|
|
1511
|
+
return { status: "unavailable" };
|
|
778
1512
|
const normalizedPath = normalizeMapKey(filePath);
|
|
1513
|
+
// #1773: a budget at or below the usable floor cannot complete a real
|
|
1514
|
+
// round trip. Skip the dispatch entirely — no `safeSendRequest` call, no
|
|
1515
|
+
// generation/sequence claim — and say so with a distinct record, so
|
|
1516
|
+
// `lsp_pull_diagnostic_timeout` only ever means a pull was genuinely
|
|
1517
|
+
// attempted. The caller sees the same `unavailable` outcome a timeout
|
|
1518
|
+
// would have produced (#240: unavailable, never a false clean), so
|
|
1519
|
+
// nothing downstream of this call changes shape.
|
|
1520
|
+
if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
|
|
1521
|
+
emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${pullSourceKey(normalizedPath, identifier)}`, {
|
|
1522
|
+
type: "phase",
|
|
1523
|
+
filePath: normalizedPath,
|
|
1524
|
+
durationMs: 0,
|
|
1525
|
+
metadata: {
|
|
1526
|
+
identifier: identifier ?? "bare",
|
|
1527
|
+
remainingBudgetMs: budgetMs,
|
|
1528
|
+
server: state.serverId,
|
|
1529
|
+
},
|
|
1530
|
+
}, {
|
|
1531
|
+
ledgerKind: "lsp-pull-skipped-budget-exhausted",
|
|
1532
|
+
reason: `pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
|
|
1533
|
+
});
|
|
1534
|
+
return { status: "unavailable" };
|
|
1535
|
+
}
|
|
1536
|
+
const uri = pathToFileURL(filePath).href;
|
|
779
1537
|
// #1104: echo the last resultId we hold for this document so a server that
|
|
780
1538
|
// hasn't changed its view can answer `kind: "unchanged"` instead of
|
|
781
1539
|
// recomputing — see the `kind === "unchanged"` branch below for how that's
|
|
782
1540
|
// honored (inherit, never treat an omitted `items` as clean).
|
|
783
|
-
|
|
1541
|
+
// #1667: read THIS source's id, never another source's. Result ids are
|
|
1542
|
+
// issued per source, so echoing a syntax id on a semantic request asks the
|
|
1543
|
+
// server to compare against a basis it never handed out.
|
|
1544
|
+
const sourceKey = pullSourceKey(normalizedPath, identifier);
|
|
1545
|
+
if (state.abandonedPullRequests?.has(sourceKey)) {
|
|
1546
|
+
return { status: "unavailable" };
|
|
1547
|
+
}
|
|
1548
|
+
const previousResultId = state.pullResultIds.get(sourceKey);
|
|
1549
|
+
// #1667: the generation this pull was computed against. A resync landing
|
|
1550
|
+
// while the request is in flight bumps it, and every write below is dropped
|
|
1551
|
+
// rather than resurrecting state the resync cleared.
|
|
1552
|
+
const generation = pullGenerationFor(state, normalizedPath);
|
|
1553
|
+
// #1667: claim the newest-request slot for THIS source. Two fan-outs can
|
|
1554
|
+
// overlap for the same file with no resync between them (`ensureWarmForSweep`
|
|
1555
|
+
// touches, then the real touch follows ~60ms later), so the generation check
|
|
1556
|
+
// alone would let a slow answer from the earlier round land on top of a newer
|
|
1557
|
+
// one. Re-checked at write time below.
|
|
1558
|
+
const requestSequence = claimPullRequestSequence(state, sourceKey);
|
|
1559
|
+
// #1889: give the request a cancellation token. `withTimeout` only abandons
|
|
1560
|
+
// its await; aborting in the timeout branch below also sends `$/cancelRequest`
|
|
1561
|
+
// so repeated touches cannot leave an aging backlog inside the server.
|
|
1562
|
+
const requestStartedAt = Date.now();
|
|
1563
|
+
const effectiveTimeoutMs = Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs));
|
|
1564
|
+
const pullAbort = new AbortController();
|
|
1565
|
+
const pullSettlement = { cancelled: false };
|
|
1566
|
+
const requestPromise = safeSendRequest(state.connection, "textDocument/diagnostic", {
|
|
1567
|
+
textDocument: { uri },
|
|
1568
|
+
// #1667: name the source this request is for, so the server answers
|
|
1569
|
+
// from that source instead of its default one.
|
|
1570
|
+
...(identifier !== undefined && { identifier }),
|
|
1571
|
+
...(previousResultId !== undefined && { previousResultId }),
|
|
1572
|
+
}, pullAbort.signal, pullSettlement);
|
|
784
1573
|
try {
|
|
785
1574
|
// withTimeout is the backstop against a hung pull-mode server: without it
|
|
786
1575
|
// this await never settles unless the stream is destroyed. Bounded by the
|
|
787
1576
|
// smaller of the absolute ceiling and the caller's remaining wait budget.
|
|
788
1577
|
// On timeout the caught error yields `unavailable` below (never a false
|
|
789
1578
|
// `clean`), so it falls through to the push-wait/timeout backstop.
|
|
790
|
-
const report = await withTimeout(
|
|
791
|
-
textDocument: { uri },
|
|
792
|
-
...(previousResultId !== undefined && { previousResultId }),
|
|
793
|
-
}), Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)));
|
|
1579
|
+
const report = await withTimeout(requestPromise, effectiveTimeoutMs);
|
|
794
1580
|
if (!report) {
|
|
795
1581
|
recordPullFailure(state, "textDocument/diagnostic", new Error("empty response"));
|
|
796
1582
|
return { status: "unavailable" };
|
|
@@ -803,13 +1589,28 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
|
|
|
803
1589
|
// response describes whatever the server had when it answered, which for
|
|
804
1590
|
// a pi-lens-opened document is exactly that last-sent payload.
|
|
805
1591
|
const sentHash = state.documentContentHashes.get(normalizedPath)?.hash;
|
|
806
|
-
|
|
1592
|
+
// #1667: two ways this answer can already be obsolete, and neither may
|
|
1593
|
+
// write. Report the round trip as unavailable - a superseded answer is not
|
|
1594
|
+
// evidence the file is clean (#240).
|
|
1595
|
+
// - A resync landed while the request was in flight, so these findings
|
|
1596
|
+
// describe content that no longer exists.
|
|
1597
|
+
// - A LATER request for this same source has already been issued (an
|
|
1598
|
+
// overlapping fan-out), so this answer is the stale loser and would
|
|
1599
|
+
// clobber the newer result.
|
|
1600
|
+
if (pullGenerationFor(state, normalizedPath) !== generation ||
|
|
1601
|
+
!isNewestPullRequest(state, sourceKey, requestSequence)) {
|
|
1602
|
+
return { status: "unavailable" };
|
|
1603
|
+
}
|
|
1604
|
+
let primaryCount;
|
|
807
1605
|
if (report.kind === "unchanged") {
|
|
808
1606
|
// #1104: same resultId basis as last time — an omitted `items` here
|
|
809
1607
|
// means "no change", NOT "clean". Overwriting with `[]` would be the
|
|
810
1608
|
// exact false-clean shape #570/#571 already fixed for the touch path;
|
|
811
1609
|
// keep the previously stored diagnostics and binding as-is.
|
|
812
|
-
|
|
1610
|
+
// #1667: "the previously stored diagnostics" means THIS SOURCE's, so
|
|
1611
|
+
// one source reporting unchanged neither inherits nor disturbs another
|
|
1612
|
+
// source's findings for the same file.
|
|
1613
|
+
primaryCount = pullSourceDiagnostics(state, normalizedPath, identifier).length;
|
|
813
1614
|
// Still a fresh confirmation as of `now` even though the content is
|
|
814
1615
|
// unchanged — bump the timestamp so `getAllDiagnostics()` doesn't read
|
|
815
1616
|
// this entry as aging purely because the server had nothing new to say.
|
|
@@ -817,30 +1618,43 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
|
|
|
817
1618
|
}
|
|
818
1619
|
else {
|
|
819
1620
|
const primaryItems = normalizeLspDiagnostics(report.items ?? []);
|
|
820
|
-
|
|
1621
|
+
// #1667: replace only this source's slice; `documentPullDiagnostics`
|
|
1622
|
+
// becomes the deduped union of every source that has reported.
|
|
1623
|
+
storePullSourceDiagnostics(state, normalizedPath, identifier, primaryItems);
|
|
821
1624
|
state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
|
|
822
|
-
state
|
|
1625
|
+
bumpDiagnosticsVersion(state, normalizedPath);
|
|
823
1626
|
state.diagnosticBindings.set(normalizedPath, { contentHash: sentHash });
|
|
824
|
-
|
|
1627
|
+
primaryCount = primaryItems.length;
|
|
825
1628
|
}
|
|
1629
|
+
let totalCount = primaryCount;
|
|
826
1630
|
if (report.resultId !== undefined) {
|
|
827
|
-
state.pullResultIds.set(
|
|
1631
|
+
state.pullResultIds.set(sourceKey, report.resultId);
|
|
828
1632
|
}
|
|
829
1633
|
else {
|
|
830
|
-
state.pullResultIds.delete(
|
|
1634
|
+
state.pullResultIds.delete(sourceKey);
|
|
831
1635
|
}
|
|
1636
|
+
// #1531 note (pre-existing, unchanged): a related document's diagnostics are
|
|
1637
|
+
// STORED below but earn no version bump and therefore no per-path stamp —
|
|
1638
|
+
// exactly as before, since this loop never bumped the global counter either.
|
|
1639
|
+
// A wait on a path that only ever hears about itself through some other
|
|
1640
|
+
// document's `relatedDocuments` sees no freshness evidence and rides its
|
|
1641
|
+
// timeout. Direction is under-detection, and correcting it means deciding
|
|
1642
|
+
// what a related-document publication is evidence OF (its binding is
|
|
1643
|
+
// honestly "unknown" per #1104), so it stays out of scope here.
|
|
832
1644
|
if (report.relatedDocuments) {
|
|
833
1645
|
for (const [relatedUri, related] of Object.entries(report.relatedDocuments)) {
|
|
834
1646
|
const relatedPath = uriToPath(relatedUri);
|
|
835
1647
|
const relatedNormalized = normalizeMapKey(relatedPath);
|
|
1648
|
+
// #1667: a related document's report belongs to the SAME source as
|
|
1649
|
+
// the request that carried it, so it stores and inherits under that
|
|
1650
|
+
// source's key too.
|
|
836
1651
|
if (related?.kind === "unchanged") {
|
|
837
|
-
totalCount +=
|
|
838
|
-
state.documentPullDiagnostics.get(relatedNormalized)?.length ?? 0;
|
|
1652
|
+
totalCount += pullSourceDiagnostics(state, relatedNormalized, identifier).length;
|
|
839
1653
|
state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
|
|
840
1654
|
}
|
|
841
1655
|
else {
|
|
842
1656
|
const relatedItems = normalizeLspDiagnostics(related?.items ?? []);
|
|
843
|
-
state
|
|
1657
|
+
storePullSourceDiagnostics(state, relatedNormalized, identifier, relatedItems);
|
|
844
1658
|
state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
|
|
845
1659
|
// #1104: a related document's diagnostics were NOT computed against
|
|
846
1660
|
// content we independently sent/fingerprinted (we never requested
|
|
@@ -850,20 +1664,186 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
|
|
|
850
1664
|
totalCount += relatedItems.length;
|
|
851
1665
|
}
|
|
852
1666
|
if (related?.resultId !== undefined) {
|
|
853
|
-
state.pullResultIds.set(relatedNormalized, related.resultId);
|
|
1667
|
+
state.pullResultIds.set(pullSourceKey(relatedNormalized, identifier), related.resultId);
|
|
854
1668
|
}
|
|
855
1669
|
}
|
|
856
1670
|
}
|
|
857
1671
|
state.diagnosticEmitter.emit("diagnostics", normalizedPath);
|
|
858
1672
|
return totalCount > 0
|
|
859
|
-
? { status: "found", count: totalCount }
|
|
1673
|
+
? { status: "found", count: totalCount, primaryCount }
|
|
860
1674
|
: { status: "clean" };
|
|
861
1675
|
}
|
|
862
1676
|
catch (err) {
|
|
1677
|
+
if (isPullTimeoutError(err, effectiveTimeoutMs)) {
|
|
1678
|
+
trackAbandonedPullRequest(state, sourceKey, requestPromise);
|
|
1679
|
+
pullAbort.abort();
|
|
1680
|
+
recordPullTimeoutTelemetry({
|
|
1681
|
+
scope: normalizedPath,
|
|
1682
|
+
identifier,
|
|
1683
|
+
effectiveBudgetMs: effectiveTimeoutMs,
|
|
1684
|
+
hadPreviousResultId: previousResultId !== undefined,
|
|
1685
|
+
elapsedMs: Date.now() - requestStartedAt,
|
|
1686
|
+
server: state.serverId,
|
|
1687
|
+
});
|
|
1688
|
+
armLateAnswerTelemetry({
|
|
1689
|
+
requestPromise,
|
|
1690
|
+
scope: normalizedPath,
|
|
1691
|
+
identifier,
|
|
1692
|
+
subject: sourceKey,
|
|
1693
|
+
requestStartedAt,
|
|
1694
|
+
server: state.serverId,
|
|
1695
|
+
settlement: pullSettlement,
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
863
1698
|
recordPullFailure(state, "textDocument/diagnostic", err);
|
|
864
1699
|
return { status: "unavailable" };
|
|
865
1700
|
}
|
|
866
1701
|
}
|
|
1702
|
+
function trackAbandonedPullRequest(state, sourceKey, requestPromise) {
|
|
1703
|
+
if (!state.abandonedPullRequests)
|
|
1704
|
+
state.abandonedPullRequests = new Map();
|
|
1705
|
+
state.abandonedPullRequests.set(sourceKey, requestPromise);
|
|
1706
|
+
const release = () => {
|
|
1707
|
+
if (state.abandonedPullRequests?.get(sourceKey) === requestPromise) {
|
|
1708
|
+
state.abandonedPullRequests.delete(sourceKey);
|
|
1709
|
+
}
|
|
1710
|
+
};
|
|
1711
|
+
requestPromise.then(release, release);
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* #1713: `withTimeout` rejects with this EXACT message (`clients/deadline-utils.ts`)
|
|
1715
|
+
* only when its own timer wins the race — every other rejection is the request
|
|
1716
|
+
* itself failing (bad params, dead connection, thrown by the server). Only the
|
|
1717
|
+
* timer-won case is a "the answer might still be coming" situation worth a
|
|
1718
|
+
* late-answer watch; a request that already failed fast has nothing further to
|
|
1719
|
+
* observe.
|
|
1720
|
+
*/
|
|
1721
|
+
function isPullTimeoutError(err, timeoutMs) {
|
|
1722
|
+
return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* #1713: every pull timeout emits exactly one bounded latency.log record — the
|
|
1726
|
+
* observability gap the issue names (`withTimeout`'s throw previously skipped
|
|
1727
|
+
* ALL telemetry for this site). Never throws: telemetry must not perturb the
|
|
1728
|
+
* timeout path, which has already decided to return `unavailable` regardless.
|
|
1729
|
+
*
|
|
1730
|
+
* #1743: routed through `emitBounded`. This phase takes NO rising-edge gate
|
|
1731
|
+
* and NO per-turn cap, which is the pre-existing behavior kept exactly: a
|
|
1732
|
+
* pull timeout costs at least the full budget (seconds), so the call cadence
|
|
1733
|
+
* already bounds the volume, unlike `navRequest` below. The helper still
|
|
1734
|
+
* supplies the identity discipline and the registry membership the sweep
|
|
1735
|
+
* checks.
|
|
1736
|
+
*/
|
|
1737
|
+
function recordPullTimeoutTelemetry(args) {
|
|
1738
|
+
// #1771: this record is written on a genuine failure path (a dispatched
|
|
1739
|
+
// pull that never answered), so per the bounded-telemetry rule
|
|
1740
|
+
// (`clients/bounded-telemetry.ts`) it needs a `ledgerKind`, not just the
|
|
1741
|
+
// detailed latency.log record. Before this it counted nothing in the
|
|
1742
|
+
// degradation ledger — every occurrence wrote a detail line, but nothing
|
|
1743
|
+
// tallied. No `risingEdgePer`: timeouts repeat and every one should count,
|
|
1744
|
+
// matching the pre-existing "no rising-edge gate" behavior for the
|
|
1745
|
+
// detailed record itself.
|
|
1746
|
+
emitBounded("lsp_pull_diagnostic_timeout", `${args.server}::${args.scope}::${args.identifier ?? "bare"}`, {
|
|
1747
|
+
type: "phase",
|
|
1748
|
+
filePath: args.scope,
|
|
1749
|
+
durationMs: args.elapsedMs,
|
|
1750
|
+
metadata: {
|
|
1751
|
+
identifier: args.identifier ?? "bare",
|
|
1752
|
+
effectiveBudgetMs: args.effectiveBudgetMs,
|
|
1753
|
+
hadPreviousResultId: args.hadPreviousResultId,
|
|
1754
|
+
server: args.server,
|
|
1755
|
+
},
|
|
1756
|
+
}, {
|
|
1757
|
+
ledgerKind: "lsp-pull-diagnostic-timeout",
|
|
1758
|
+
reason: `pull timeout on ${args.server} after ${args.elapsedMs}ms (budget ${args.effectiveBudgetMs}ms)`,
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* #1713: a telemetry-only continuation on the ALREADY-abandoned request
|
|
1763
|
+
* promise. `withTimeout` only raced it against a timer — the request is still
|
|
1764
|
+
* live server-side — so if it eventually resolves, this is the only place that
|
|
1765
|
+
* ever finds out. Purely observational: it runs after the caller has already
|
|
1766
|
+
* moved on with `unavailable`, and it never writes into `state`.
|
|
1767
|
+
*
|
|
1768
|
+
* Bounded via the degradation-ledger convention (`incrementDegradationCount`,
|
|
1769
|
+
* chosen over a hand-rolled per-identity rising-edge Set): the ledger already
|
|
1770
|
+
* caps entries per kind and re-arms at `session_start`
|
|
1771
|
+
* (`resetDegradationLedger`, wired into `handleSessionStart`), so reusing it
|
|
1772
|
+
* avoids a second latch this fix would otherwise have to register and reset
|
|
1773
|
+
* itself. `subject` is the same `pullSourceKey` (path + identifier) the pull
|
|
1774
|
+
* path already uses, so the discriminating identity survives aggregation.
|
|
1775
|
+
*
|
|
1776
|
+
* Attaching `.then` here creates no timer, socket, or other handle — it only
|
|
1777
|
+
* observes a promise the request already created — so it cannot keep the
|
|
1778
|
+
* process alive a moment longer than that pending request already does.
|
|
1779
|
+
*/
|
|
1780
|
+
function armLateAnswerTelemetry(args) {
|
|
1781
|
+
args.requestPromise.then(() => {
|
|
1782
|
+
if (args.settlement.cancelled)
|
|
1783
|
+
return;
|
|
1784
|
+
try {
|
|
1785
|
+
const elapsedMs = Date.now() - args.requestStartedAt;
|
|
1786
|
+
// #1743: `emitBounded` counts every late answer in the ledger
|
|
1787
|
+
// under the same `pullSourceKey` subject as before, then writes
|
|
1788
|
+
// the record. No rising-edge gate, matching #1713's original
|
|
1789
|
+
// behavior: a late answer arrives at most once per abandoned
|
|
1790
|
+
// request, so the record count cannot exceed the timeout count.
|
|
1791
|
+
emitBounded("lsp_pull_late_answer_discarded", args.subject, {
|
|
1792
|
+
type: "phase",
|
|
1793
|
+
filePath: args.scope,
|
|
1794
|
+
durationMs: elapsedMs,
|
|
1795
|
+
metadata: { identifier: args.identifier ?? "bare" },
|
|
1796
|
+
}, {
|
|
1797
|
+
ledgerKind: "lsp-pull-late-answer",
|
|
1798
|
+
reason: `late pull answer discarded after ${elapsedMs}ms`,
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
catch {
|
|
1802
|
+
// Telemetry must never break the observed path.
|
|
1803
|
+
}
|
|
1804
|
+
}, (err) => {
|
|
1805
|
+
// #1774: the abandoned request eventually REJECTED rather than
|
|
1806
|
+
// answering. Behavior is unchanged — the rejection is still
|
|
1807
|
+
// swallowed here, exactly as before — but "timeout then silence"
|
|
1808
|
+
// and "timeout then server rejection (e.g. ContentModified)" were
|
|
1809
|
+
// previously indistinguishable: `recordPullFailure` at request
|
|
1810
|
+
// time reports the FIRST failure only, and this rejection happens
|
|
1811
|
+
// strictly after that request already timed out, so it needs its
|
|
1812
|
+
// own record. Bounded via the degradation ledger, same shape as
|
|
1813
|
+
// the late-answer branch above, so this rejection cannot outpace
|
|
1814
|
+
// the timeout count that gates it.
|
|
1815
|
+
try {
|
|
1816
|
+
const elapsedMs = Date.now() - args.requestStartedAt;
|
|
1817
|
+
const candidate = err;
|
|
1818
|
+
const code = typeof candidate?.code === "number" ||
|
|
1819
|
+
typeof candidate?.code === "string"
|
|
1820
|
+
? candidate.code
|
|
1821
|
+
: undefined;
|
|
1822
|
+
emitBounded("lsp_pull_late_rejection",
|
|
1823
|
+
// #1774 review: prefix the server, same reason the timeout kind
|
|
1824
|
+
// does (#1771). The workspace call site's `subject` is the bare
|
|
1825
|
+
// `WORKSPACE_PULL_SCOPE` constant — without the server prefix,
|
|
1826
|
+
// two servers' abandoned-workspace-pull rejections would collapse
|
|
1827
|
+
// into one ledger subject and hide which server is storming.
|
|
1828
|
+
`${args.server}::${args.subject}`, {
|
|
1829
|
+
type: "phase",
|
|
1830
|
+
filePath: args.scope,
|
|
1831
|
+
durationMs: elapsedMs,
|
|
1832
|
+
metadata: {
|
|
1833
|
+
identifier: args.identifier ?? "bare",
|
|
1834
|
+
server: args.server,
|
|
1835
|
+
...(code !== undefined && { code }),
|
|
1836
|
+
},
|
|
1837
|
+
}, {
|
|
1838
|
+
ledgerKind: "lsp-pull-late-rejection",
|
|
1839
|
+
reason: `late pull rejection ${elapsedMs}ms after timeout${code !== undefined ? ` (code ${code})` : ""}`,
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
catch {
|
|
1843
|
+
// Telemetry must never break the observed path.
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
867
1847
|
const PULL_FAILURE_HISTORY_LIMIT = 10;
|
|
868
1848
|
function recordPullFailure(state, method, error) {
|
|
869
1849
|
const candidate = error;
|
|
@@ -879,9 +1859,7 @@ function recordPullFailure(state, method, error) {
|
|
|
879
1859
|
...(typeof candidate.code === "number" || typeof candidate.code === "string"
|
|
880
1860
|
? { code: candidate.code }
|
|
881
1861
|
: {}),
|
|
882
|
-
message: typeof candidate.message === "string"
|
|
883
|
-
? candidate.message
|
|
884
|
-
: String(error),
|
|
1862
|
+
message: typeof candidate.message === "string" ? candidate.message : String(error),
|
|
885
1863
|
});
|
|
886
1864
|
if (state.pullFailureHistory.length > PULL_FAILURE_HISTORY_LIMIT) {
|
|
887
1865
|
state.pullFailureHistory.splice(0, state.pullFailureHistory.length - PULL_FAILURE_HISTORY_LIMIT);
|
|
@@ -899,15 +1877,58 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
|
|
|
899
1877
|
return undefined;
|
|
900
1878
|
if (!state.workspaceDiagnosticsSupport.workspaceDiagnostics)
|
|
901
1879
|
return undefined;
|
|
1880
|
+
if (state.abandonedPullRequests?.has(WORKSPACE_PULL_SCOPE))
|
|
1881
|
+
return undefined;
|
|
1882
|
+
// #1104: echo every resultId we hold from a PRIOR pull so the server can
|
|
1883
|
+
// answer `kind: "unchanged"` for files it hasn't recomputed, instead of
|
|
1884
|
+
// resending (and us re-hashing) every file on every sweep.
|
|
1885
|
+
const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
|
|
1886
|
+
// #1773: same floor as `pullDiagnosticSource` — an exhausted budget here is
|
|
1887
|
+
// the SAME clamp-then-dispatch defect via a second entry point. Observed
|
|
1888
|
+
// live in the 2026-08-20 plegma dogfood session via
|
|
1889
|
+
// `lsp_workspace_diagnostics_start` (not just the `lens_diagnostics_full`
|
|
1890
|
+
// fan-out `pullDiagnosticSource` covers), so this call site needs its own
|
|
1891
|
+
// skip, not just a shared helper the caller happens to hit.
|
|
1892
|
+
if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
|
|
1893
|
+
emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${WORKSPACE_PULL_SCOPE}`, {
|
|
1894
|
+
type: "phase",
|
|
1895
|
+
filePath: WORKSPACE_PULL_SCOPE,
|
|
1896
|
+
durationMs: 0,
|
|
1897
|
+
metadata: {
|
|
1898
|
+
identifier: "bare",
|
|
1899
|
+
remainingBudgetMs: budgetMs,
|
|
1900
|
+
server: state.serverId,
|
|
1901
|
+
},
|
|
1902
|
+
}, {
|
|
1903
|
+
ledgerKind: "lsp-pull-skipped-budget-exhausted",
|
|
1904
|
+
reason: `workspace pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
|
|
1905
|
+
});
|
|
1906
|
+
return undefined;
|
|
1907
|
+
}
|
|
1908
|
+
// #1713: declared OUTSIDE the try so the catch below can still reach them —
|
|
1909
|
+
// same shape as `pullDiagnosticSource` above. Captured outside `withTimeout`
|
|
1910
|
+
// so a timeout still holds a live handle on the request for the late-answer
|
|
1911
|
+
// watch.
|
|
1912
|
+
const workspacePullStartedAt = Date.now();
|
|
1913
|
+
const workspacePullTimeoutMs = Math.max(1, budgetMs);
|
|
1914
|
+
const workspacePullAbort = new AbortController();
|
|
1915
|
+
const workspacePullSettlement = { cancelled: false };
|
|
1916
|
+
const workspaceRequestPromise = safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }, workspacePullAbort.signal, workspacePullSettlement);
|
|
902
1917
|
try {
|
|
903
|
-
|
|
904
|
-
// answer `kind: "unchanged"` for files it hasn't recomputed, instead of
|
|
905
|
-
// resending (and us re-hashing) every file on every sweep.
|
|
906
|
-
const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
|
|
907
|
-
const report = await withTimeout(safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }), Math.max(1, budgetMs));
|
|
1918
|
+
const report = await withTimeout(workspaceRequestPromise, workspacePullTimeoutMs);
|
|
908
1919
|
if (!report || !Array.isArray(report.items))
|
|
909
1920
|
return undefined;
|
|
910
1921
|
const out = [];
|
|
1922
|
+
// #1669 review N1: ONE deadline, shared across every "unchanged, no
|
|
1923
|
+
// cached basis" fallback pull this call makes — not a fresh `budgetMs`
|
|
1924
|
+
// grant handed to EACH one. A refresh clears `workspacePullResultCache`,
|
|
1925
|
+
// so the very next sweep after a refresh can route every item down this
|
|
1926
|
+
// path; granting each one the full budget serially turned a bounded call
|
|
1927
|
+
// unbounded (measured: 6 files x 300ms budget = 1844ms; worst case is
|
|
1928
|
+
// the normal case — 2000 files x 30s). Mirrors the shared-budget shape
|
|
1929
|
+
// `runWorkspaceDiagnosticsSwept`'s `perFileMs` bounds each file to,
|
|
1930
|
+
// scoped to fallback pulls made from within THIS one call.
|
|
1931
|
+
const fallbackPullDeadline = Date.now() + Math.max(1, budgetMs);
|
|
911
1932
|
for (const item of report.items) {
|
|
912
1933
|
if (!item?.uri)
|
|
913
1934
|
continue;
|
|
@@ -919,8 +1940,53 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
|
|
|
919
1940
|
// `items`, so without this a file the server confirmed unchanged
|
|
920
1941
|
// would silently drop out of the sweep result entirely.
|
|
921
1942
|
const prior = state.workspacePullResultCache.get(normalizedPath);
|
|
922
|
-
if (!prior)
|
|
923
|
-
|
|
1943
|
+
if (!prior) {
|
|
1944
|
+
// #1669 review F4: no earlier basis to inherit (e.g. right after a
|
|
1945
|
+
// `workspace/diagnostic/refresh` cleared `workspacePullResultCache`)
|
|
1946
|
+
// — the server is answering "unchanged" against a resultId basis we
|
|
1947
|
+
// no longer hold, so there is nothing honest to report for this
|
|
1948
|
+
// file from THIS response. `continue`-ing here would make the file
|
|
1949
|
+
// silently absent from `out`, which the caller reads as "clean" —
|
|
1950
|
+
// a false clean for a file the server just told us it hasn't even
|
|
1951
|
+
// looked at freshly. Treat it as needs-full-pull: request the
|
|
1952
|
+
// per-file diagnostic directly instead.
|
|
1953
|
+
const remainingMs = fallbackPullDeadline - Date.now();
|
|
1954
|
+
if (remainingMs <= 0) {
|
|
1955
|
+
// #1669 review N1: the shared fallback-pull deadline is already
|
|
1956
|
+
// exhausted by earlier files in this same loop — bail WITHOUT
|
|
1957
|
+
// attempting a request (never fabricate clean; this file was
|
|
1958
|
+
// never actually asked about) and surface it as unavailable via
|
|
1959
|
+
// the same degradation ledger path as a real unavailable pull.
|
|
1960
|
+
incrementDegradationCount({
|
|
1961
|
+
kind: "lsp-pull-unconfirmed",
|
|
1962
|
+
subject: state.serverId,
|
|
1963
|
+
reason: "unchanged report with no workspacePullResultCache basis; the shared fallback-pull deadline was already exhausted by earlier files this sweep",
|
|
1964
|
+
});
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
const outcome = await clientRequestPullDiagnostics(state, filePath, remainingMs);
|
|
1968
|
+
if (outcome.status === "found") {
|
|
1969
|
+
out.push({
|
|
1970
|
+
filePath,
|
|
1971
|
+
diagnostics: state.documentPullDiagnostics.get(normalizedPath) ?? [],
|
|
1972
|
+
contentHash: state.diagnosticBindings.get(normalizedPath)?.contentHash,
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
else if (outcome.status === "unavailable") {
|
|
1976
|
+
// Genuinely unconfirmed — never fabricate a clean result. Recorded
|
|
1977
|
+
// via the shared degradation ledger (AGENTS.md: repeated
|
|
1978
|
+
// degradations use incrementDegradationCount, not a hand-rolled
|
|
1979
|
+
// counter) so a server that keeps racing refresh against sweeps
|
|
1980
|
+
// is visible in aggregate instead of silently absorbed per-file.
|
|
1981
|
+
incrementDegradationCount({
|
|
1982
|
+
kind: "lsp-pull-unconfirmed",
|
|
1983
|
+
subject: state.serverId,
|
|
1984
|
+
reason: "unchanged report with no workspacePullResultCache basis, and the per-file fallback pull was unavailable",
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
// "clean": genuinely clean, correctly absent from `out`.
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
924
1990
|
if (item.resultId !== undefined) {
|
|
925
1991
|
state.workspacePullResultCache.set(normalizedPath, {
|
|
926
1992
|
...prior,
|
|
@@ -966,6 +2032,27 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
|
|
|
966
2032
|
return out;
|
|
967
2033
|
}
|
|
968
2034
|
catch (err) {
|
|
2035
|
+
if (isPullTimeoutError(err, workspacePullTimeoutMs)) {
|
|
2036
|
+
trackAbandonedPullRequest(state, WORKSPACE_PULL_SCOPE, workspaceRequestPromise);
|
|
2037
|
+
workspacePullAbort.abort();
|
|
2038
|
+
recordPullTimeoutTelemetry({
|
|
2039
|
+
scope: WORKSPACE_PULL_SCOPE,
|
|
2040
|
+
identifier: undefined,
|
|
2041
|
+
effectiveBudgetMs: workspacePullTimeoutMs,
|
|
2042
|
+
hadPreviousResultId: previousResultIds.length > 0,
|
|
2043
|
+
elapsedMs: Date.now() - workspacePullStartedAt,
|
|
2044
|
+
server: state.serverId,
|
|
2045
|
+
});
|
|
2046
|
+
armLateAnswerTelemetry({
|
|
2047
|
+
requestPromise: workspaceRequestPromise,
|
|
2048
|
+
scope: WORKSPACE_PULL_SCOPE,
|
|
2049
|
+
identifier: undefined,
|
|
2050
|
+
subject: WORKSPACE_PULL_SCOPE,
|
|
2051
|
+
requestStartedAt: workspacePullStartedAt,
|
|
2052
|
+
server: state.serverId,
|
|
2053
|
+
settlement: workspacePullSettlement,
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
969
2056
|
recordPullFailure(state, "workspace/diagnostic", err);
|
|
970
2057
|
return undefined;
|
|
971
2058
|
}
|
|
@@ -973,7 +2060,17 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
|
|
|
973
2060
|
export async function clientWaitForDiagnostics(state, filePath, timeoutMs, options = {}) {
|
|
974
2061
|
const normalizedPath = normalizeMapKey(filePath);
|
|
975
2062
|
const minVersion = options.minVersion;
|
|
976
|
-
const
|
|
2063
|
+
const pullSettleSource = options.pullSettleSource ?? "pull";
|
|
2064
|
+
// #1531: the freshness gate is PER PATH. `minVersion` is a reading of the
|
|
2065
|
+
// client-global counter, and `diagnosticsVersionsByPath` stores that same
|
|
2066
|
+
// counter's value at each store — the two are on one axis, so comparing them
|
|
2067
|
+
// asks "has a publication landed for THIS file since the baseline?" instead of
|
|
2068
|
+
// "has anything at all landed on this client?". Reading the global counter here
|
|
2069
|
+
// let a sibling file's publication end this file's wait before its own budget
|
|
2070
|
+
// lapsed, which then labelled the outcome row `silent` (reserved for "own
|
|
2071
|
+
// budget lapsed with nothing published") instead of `cut_off`.
|
|
2072
|
+
const hasFreshDiagnostics = () => minVersion === undefined ||
|
|
2073
|
+
diagnosticsVersionForPath(state, normalizedPath) > minVersion;
|
|
977
2074
|
// Version coherence: a cached push is "stale" only when the server reported
|
|
978
2075
|
// the document version it computed against AND that version lags the latest
|
|
979
2076
|
// didChange we sent. This prevents serving diagnostics from a superseded
|
|
@@ -997,11 +2094,17 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
|
|
|
997
2094
|
// `hasFreshDiagnostics()`, which is unconditionally true when there is no
|
|
998
2095
|
// version baseline (`minVersion === undefined`), so a failed pull returned
|
|
999
2096
|
// 0 and was read as a fresh clean.
|
|
2097
|
+
// #1639: the settle operation's OWN clock — durationMs on the eventual
|
|
2098
|
+
// log record measures from here, never from didOpen (that stays in
|
|
2099
|
+
// metadata.elapsedSinceDidOpenMs, computed separately below).
|
|
2100
|
+
const pullSettleStartedAt = Date.now();
|
|
1000
2101
|
let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
|
|
1001
|
-
if (outcome.status === "found")
|
|
2102
|
+
if (outcome.status === "found") {
|
|
2103
|
+
logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
|
|
1002
2104
|
return;
|
|
2105
|
+
}
|
|
1003
2106
|
let sawClean = outcome.status === "clean";
|
|
1004
|
-
const strategy = getStrategy(state.serverId);
|
|
2107
|
+
const strategy = getStrategy(state.serverId, state.launchVariant);
|
|
1005
2108
|
const retryBudgetMs = strategy.pullRetryBudgetMs > 0
|
|
1006
2109
|
? Math.min(timeoutMs, strategy.pullRetryBudgetMs)
|
|
1007
2110
|
: 0;
|
|
@@ -1016,10 +2119,16 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
|
|
|
1016
2119
|
if (outcome.status === "clean")
|
|
1017
2120
|
sawClean = true;
|
|
1018
2121
|
}
|
|
1019
|
-
if (options.pullOnly)
|
|
2122
|
+
if (options.pullOnly) {
|
|
2123
|
+
if (outcome.status === "found" || sawClean) {
|
|
2124
|
+
logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
|
|
2125
|
+
}
|
|
1020
2126
|
return;
|
|
1021
|
-
|
|
2127
|
+
}
|
|
2128
|
+
if (outcome.status === "found" || sawClean) {
|
|
2129
|
+
logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
|
|
1022
2130
|
return;
|
|
2131
|
+
}
|
|
1023
2132
|
}
|
|
1024
2133
|
if (hasFreshDiagnostics() &&
|
|
1025
2134
|
!isVersionStale() &&
|
|
@@ -1037,7 +2146,7 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
|
|
|
1037
2146
|
clearTimeout(debounceTimer);
|
|
1038
2147
|
// Adaptive debounce: use time since last push to compute remaining
|
|
1039
2148
|
// wait instead of always waiting the full debounce window.
|
|
1040
|
-
const strategy = getStrategy(state.serverId);
|
|
2149
|
+
const strategy = getStrategy(state.serverId, state.launchVariant);
|
|
1041
2150
|
const hit = state.pushDiagnosticTimestamps.get(normalizedPath);
|
|
1042
2151
|
const timeSincePush = hit ? Date.now() - hit : Infinity;
|
|
1043
2152
|
const remaining = Math.max(0, strategy.debounceMs - timeSincePush);
|
|
@@ -1056,6 +2165,21 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
|
|
|
1056
2165
|
}, timeoutMs);
|
|
1057
2166
|
});
|
|
1058
2167
|
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Queue a watched-files change for a file this client did not learn about
|
|
2170
|
+
* through textDocument/didOpen or didChange — an external bash write/delete,
|
|
2171
|
+
* or any other disk change outside the open-document sync path (#1668).
|
|
2172
|
+
* Shares `handleNotifyOpen`'s per-client debounced queue (#271), so a burst
|
|
2173
|
+
* of external changes coalesces into one notification per debounce window
|
|
2174
|
+
* instead of flooding the server with one per file.
|
|
2175
|
+
*/
|
|
2176
|
+
export function handleNotifyExternalChange(state, filePath, type) {
|
|
2177
|
+
if (!isClientAlive(state))
|
|
2178
|
+
return;
|
|
2179
|
+
const normalizedPath = normalizeMapKey(filePath);
|
|
2180
|
+
const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
|
|
2181
|
+
state.watchQueue.enqueue(uri, type);
|
|
2182
|
+
}
|
|
1059
2183
|
export async function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
|
|
1060
2184
|
if (!isClientAlive(state))
|
|
1061
2185
|
return;
|
|
@@ -1080,7 +2204,7 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
|
|
|
1080
2204
|
// Scanners that only re-scan on a fresh open (opengrep ignores didChange):
|
|
1081
2205
|
// close + reopen so the re-edit actually triggers a re-scan instead of
|
|
1082
2206
|
// silently publishing nothing.
|
|
1083
|
-
if (getStrategy(state.serverId).reopenOnResync) {
|
|
2207
|
+
if (getStrategy(state.serverId, state.launchVariant).reopenOnResync) {
|
|
1084
2208
|
await safeSendNotification(state.connection, "textDocument/didClose", {
|
|
1085
2209
|
textDocument: { uri },
|
|
1086
2210
|
});
|
|
@@ -1096,25 +2220,31 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
|
|
|
1096
2220
|
// for a stale view (worse than "unknown"). Monotonic versions make that
|
|
1097
2221
|
// late echo strictly older → dropped by isSupersededPush → never bound.
|
|
1098
2222
|
state.documentVersions.set(normalizedPath, version);
|
|
2223
|
+
state.documentOpenedAt.set(normalizedPath, Date.now());
|
|
2224
|
+
state.diagnosticPublicationCounts.set(normalizedPath, 0);
|
|
1099
2225
|
if (!isClientAlive(state))
|
|
1100
2226
|
return;
|
|
1101
|
-
await safeSendNotification(state.connection, "textDocument/didOpen", {
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
2227
|
+
const reopenSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version, text: content } });
|
|
2228
|
+
// #1669 review F7: only mirror the send locally once it actually left
|
|
2229
|
+
// the process — see safeSendNotification's doc comment.
|
|
2230
|
+
if (reopenSent)
|
|
2231
|
+
recordSentContent(state, normalizedPath, version, content);
|
|
1105
2232
|
state.openDocuments.add(normalizedPath);
|
|
1106
2233
|
state.openDocumentUris?.set(normalizedPath, uri);
|
|
1107
2234
|
return;
|
|
1108
2235
|
}
|
|
1109
|
-
await safeSendNotification(state.connection, "textDocument/didChange", {
|
|
2236
|
+
const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
|
|
1110
2237
|
textDocument: { uri, version },
|
|
1111
|
-
contentChanges:
|
|
2238
|
+
contentChanges: buildContentChanges(state, normalizedPath, content),
|
|
1112
2239
|
});
|
|
1113
|
-
|
|
2240
|
+
if (changeSent)
|
|
2241
|
+
recordSentContent(state, normalizedPath, version, content);
|
|
1114
2242
|
return;
|
|
1115
2243
|
}
|
|
1116
2244
|
state.pendingOpens.add(normalizedPath);
|
|
1117
2245
|
state.documentVersions.set(normalizedPath, 0);
|
|
2246
|
+
state.documentOpenedAt.set(normalizedPath, Date.now());
|
|
2247
|
+
state.diagnosticPublicationCounts.set(normalizedPath, 0);
|
|
1118
2248
|
clearDiagnosticsForPath(state, normalizedPath); // always clear for initial open
|
|
1119
2249
|
// Send workspace notification first (like opencode does).
|
|
1120
2250
|
// Skipped in silent mode — cascade reads a file for diagnostics,
|
|
@@ -1141,14 +2271,31 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
|
|
|
1141
2271
|
}
|
|
1142
2272
|
if (!isClientAlive(state))
|
|
1143
2273
|
return;
|
|
1144
|
-
await safeSendNotification(state.connection, "textDocument/didOpen", {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
recordSentContent(state, normalizedPath, 0, content);
|
|
2274
|
+
const openSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version: 0, text: content } });
|
|
2275
|
+
if (openSent)
|
|
2276
|
+
recordSentContent(state, normalizedPath, 0, content);
|
|
1148
2277
|
state.pendingOpens.delete(normalizedPath);
|
|
1149
2278
|
state.openDocuments.add(normalizedPath);
|
|
1150
2279
|
state.closedDocuments?.delete(normalizedPath);
|
|
1151
2280
|
state.openDocumentUris?.set(normalizedPath, uri);
|
|
2281
|
+
// Telemetry is deliberately detached after didOpen succeeds.
|
|
2282
|
+
// #1412 H1: routed through runReadOnlyServerCommand, NOT runServerCommand —
|
|
2283
|
+
// the probe must never open the serverEditsAllowed/activeMutationContext
|
|
2284
|
+
// mutation-acceptance window; it is a diagnostic sample, not a mutation, and
|
|
2285
|
+
// carries its own short PROBE_COMMAND_TIMEOUT_MS backstop. The probe itself
|
|
2286
|
+
// is classic-only and swallows every failure.
|
|
2287
|
+
void probeTsserverProjectIdentity({
|
|
2288
|
+
serverId: state.serverId,
|
|
2289
|
+
launchVariant: state.launchVariant,
|
|
2290
|
+
clientRoot: state.root,
|
|
2291
|
+
file: filePath,
|
|
2292
|
+
normalizedFile: normalizedPath,
|
|
2293
|
+
probedFiles: state.projectIdentityProbedFiles ??
|
|
2294
|
+
(state.projectIdentityProbedFiles = new Set()),
|
|
2295
|
+
commandChannel: {
|
|
2296
|
+
executeCommand: (command, args) => runReadOnlyServerCommand(state, command, args),
|
|
2297
|
+
},
|
|
2298
|
+
});
|
|
1152
2299
|
}
|
|
1153
2300
|
export async function handleNotifyChange(state, filePath, content) {
|
|
1154
2301
|
if (!isClientAlive(state))
|
|
@@ -1158,11 +2305,19 @@ export async function handleNotifyChange(state, filePath, content) {
|
|
|
1158
2305
|
if (!state.openDocuments.has(normalizedPath)) {
|
|
1159
2306
|
// Safety fallback: keep protocol ordering valid even if caller sends
|
|
1160
2307
|
// didChange before first didOpen for this document.
|
|
1161
|
-
await safeSendNotification(state.connection, "textDocument/didOpen", {
|
|
1162
|
-
textDocument: {
|
|
2308
|
+
const fallbackOpenSent = await safeSendNotification(state.connection, "textDocument/didOpen", {
|
|
2309
|
+
textDocument: {
|
|
2310
|
+
uri,
|
|
2311
|
+
languageId: "plaintext",
|
|
2312
|
+
version: 0,
|
|
2313
|
+
text: content,
|
|
2314
|
+
},
|
|
1163
2315
|
});
|
|
1164
2316
|
state.documentVersions.set(normalizedPath, 0);
|
|
1165
|
-
|
|
2317
|
+
state.documentOpenedAt.set(normalizedPath, Date.now());
|
|
2318
|
+
state.diagnosticPublicationCounts.set(normalizedPath, 0);
|
|
2319
|
+
if (fallbackOpenSent)
|
|
2320
|
+
recordSentContent(state, normalizedPath, 0, content);
|
|
1166
2321
|
state.openDocuments.add(normalizedPath);
|
|
1167
2322
|
state.openDocumentUris?.set(normalizedPath, uri);
|
|
1168
2323
|
return;
|
|
@@ -1172,11 +2327,12 @@ export async function handleNotifyChange(state, filePath, content) {
|
|
|
1172
2327
|
// Clear stale diagnostics before sending new content so waitForDiagnostics
|
|
1173
2328
|
// doesn't return immediately with the previous edit's results.
|
|
1174
2329
|
clearDiagnosticsForPath(state, normalizedPath);
|
|
1175
|
-
await safeSendNotification(state.connection, "textDocument/didChange", {
|
|
2330
|
+
const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
|
|
1176
2331
|
textDocument: { uri, version },
|
|
1177
|
-
contentChanges:
|
|
2332
|
+
contentChanges: buildContentChanges(state, normalizedPath, content),
|
|
1178
2333
|
});
|
|
1179
|
-
|
|
2334
|
+
if (changeSent)
|
|
2335
|
+
recordSentContent(state, normalizedPath, version, content);
|
|
1180
2336
|
}
|
|
1181
2337
|
/** Close a document through the same lifecycle path exposed by the client. */
|
|
1182
2338
|
export async function closeDocument(state, filePath) {
|
|
@@ -1187,16 +2343,84 @@ export async function closeDocument(state, filePath) {
|
|
|
1187
2343
|
return;
|
|
1188
2344
|
await safeSendNotification(state.connection, "textDocument/didClose", {
|
|
1189
2345
|
textDocument: {
|
|
1190
|
-
uri: state.openDocumentUris?.get(normalizedPath) ??
|
|
2346
|
+
uri: state.openDocumentUris?.get(normalizedPath) ??
|
|
2347
|
+
pathToFileURL(filePath).href,
|
|
1191
2348
|
},
|
|
1192
2349
|
});
|
|
1193
2350
|
state.openDocuments.delete(normalizedPath);
|
|
1194
2351
|
state.closedDocuments?.add(normalizedPath);
|
|
1195
2352
|
state.openDocumentUris?.delete(normalizedPath);
|
|
1196
2353
|
state.documentVersions.delete(normalizedPath);
|
|
2354
|
+
state.documentOpenedAt.delete(normalizedPath);
|
|
2355
|
+
state.diagnosticPublicationCounts.delete(normalizedPath);
|
|
2356
|
+
// #1412 L1: projectIdentityProbedFiles is a claim-once memo scoped to the
|
|
2357
|
+
// document's open lifetime (re-probing a closed-then-reopened file is
|
|
2358
|
+
// harmless and cheap) — mirror openDocuments' own per-close cleanup so it
|
|
2359
|
+
// doesn't grow unbounded across a long session's worth of open/close churn.
|
|
2360
|
+
state.projectIdentityProbedFiles?.delete(normalizedPath);
|
|
1197
2361
|
clearDiagnosticsForPath(state, normalizedPath);
|
|
1198
2362
|
}
|
|
1199
|
-
|
|
2363
|
+
/**
|
|
2364
|
+
* #1620 residual review F1: `fast` and `processExiting` only ever escalate —
|
|
2365
|
+
* a `true` demands strictly less work (or none) than a `false`, never more.
|
|
2366
|
+
* `existing` covers `requested` when it is at least as demanding on BOTH
|
|
2367
|
+
* axes, so deduping onto it can't silently downgrade what the new caller
|
|
2368
|
+
* needs (e.g. a `processExiting` caller must never inherit a teardown that
|
|
2369
|
+
* might still spawn `taskkill`).
|
|
2370
|
+
*/
|
|
2371
|
+
function isAtLeastAsAggressiveShutdown(existing, requested) {
|
|
2372
|
+
return (!!existing.fast >= !!requested.fast &&
|
|
2373
|
+
!!existing.processExiting >= !!requested.processExiting);
|
|
2374
|
+
}
|
|
2375
|
+
export function clientShutdown(state, options = {}) {
|
|
2376
|
+
// #1620 residual 3: idempotent across racing callers — see the doc comment
|
|
2377
|
+
// on `LSPClientState.shutdownPromise`. The FIRST call starts the real
|
|
2378
|
+
// teardown and stores its promise; every subsequent call (any of the 8
|
|
2379
|
+
// call sites, on the same state) whose options are no MORE aggressive
|
|
2380
|
+
// awaits that same promise instead of running the RPC handshake and
|
|
2381
|
+
// emitting `lsp_client_shutdown` again.
|
|
2382
|
+
//
|
|
2383
|
+
// #1620 residual review F1: a call whose options ARE more aggressive does
|
|
2384
|
+
// NOT dedupe — it starts (and takes over as) its own teardown instead.
|
|
2385
|
+
// The concrete failure this closes: `resetLSPService`'s session-exit path
|
|
2386
|
+
// (`index.ts`) calls `shutdown({fast:true, processExiting:true})` — the
|
|
2387
|
+
// event loop is already closing, and `processExiting` exists precisely so
|
|
2388
|
+
// this teardown never spawns `taskkill` (a closing-loop `uv_async_send`
|
|
2389
|
+
// hard-aborts, `src\win\async.c`). If a GRACEFUL teardown (default
|
|
2390
|
+
// options — e.g. `client_ceiling_lru` eviction) is already in flight on
|
|
2391
|
+
// this same client when the exit path arrives, blindly deduping would
|
|
2392
|
+
// make the exit path inherit that graceful run's own `taskkill` spawn and
|
|
2393
|
+
// its close-event wait — the exact hazard `processExiting` forbids, on
|
|
2394
|
+
// top of blocking the closing loop for however long that takes. The
|
|
2395
|
+
// weaker in-flight teardown is left to finish on its own (every step
|
|
2396
|
+
// downstream — dispose, kill, deregister — is already idempotent).
|
|
2397
|
+
if (state.shutdownPromise &&
|
|
2398
|
+
state.shutdownOptions &&
|
|
2399
|
+
isAtLeastAsAggressiveShutdown(state.shutdownOptions, options)) {
|
|
2400
|
+
return state.shutdownPromise;
|
|
2401
|
+
}
|
|
2402
|
+
state.shutdownOptions = options;
|
|
2403
|
+
const attempt = clientShutdownOnce(state, options);
|
|
2404
|
+
state.shutdownPromise = attempt;
|
|
2405
|
+
// #1620 residual review F3: a REJECTED teardown (e.g. `killProcessTree`
|
|
2406
|
+
// throwing something its own internal catches don't cover) must not
|
|
2407
|
+
// latch a permanently-rejected promise here — every later call, even one
|
|
2408
|
+
// with equal or weaker options, would dedupe onto it forever and never
|
|
2409
|
+
// retry, so the child leaks silently with no further kill/dispose/
|
|
2410
|
+
// deregister attempt. Clear the latch on rejection (only if nothing more
|
|
2411
|
+
// aggressive has already superseded it) so the NEXT call starts a fresh
|
|
2412
|
+
// attempt instead of re-handing back the same dead promise. The rejection
|
|
2413
|
+
// itself still propagates to whoever is awaiting `attempt` right now —
|
|
2414
|
+
// this only affects callers that haven't asked yet.
|
|
2415
|
+
attempt.catch(() => {
|
|
2416
|
+
if (state.shutdownPromise === attempt) {
|
|
2417
|
+
state.shutdownPromise = undefined;
|
|
2418
|
+
state.shutdownOptions = undefined;
|
|
2419
|
+
}
|
|
2420
|
+
});
|
|
2421
|
+
return attempt;
|
|
2422
|
+
}
|
|
2423
|
+
async function clientShutdownOnce(state, options) {
|
|
1200
2424
|
const shutdownStart = Date.now();
|
|
1201
2425
|
state.shutdownRequested = true;
|
|
1202
2426
|
state.isConnected = false;
|
|
@@ -1208,58 +2432,132 @@ export async function clientShutdown(state, options = {}) {
|
|
|
1208
2432
|
state.pendingOpens.clear();
|
|
1209
2433
|
state.openDocuments.clear();
|
|
1210
2434
|
state.openDocumentUris?.clear();
|
|
2435
|
+
// #1412 L1: mirror openDocuments' clear — a shut-down/evicted client's
|
|
2436
|
+
// probe memo is moot along with everything else document-scoped.
|
|
2437
|
+
state.projectIdentityProbedFiles?.clear();
|
|
1211
2438
|
// #271: drop any pending watched-files batch + its timer (a dying client's
|
|
1212
2439
|
// queued FS changes are moot, and the timer must not outlive the connection).
|
|
1213
2440
|
state.watchQueue?.cancel();
|
|
1214
2441
|
state.diagnosticEmitter.removeAllListeners();
|
|
1215
2442
|
let shutdownRequestTimedOut = false;
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
2443
|
+
let exitNotifyTimedOut = false;
|
|
2444
|
+
// #1620 residual 1 / residual-review F2: a failure that ISN'T the timer
|
|
2445
|
+
// winning was previously folded into the same "*TimedOut" flag as a real
|
|
2446
|
+
// timeout — honest for the rolled-up `shutdownOutcome` (still "forced"
|
|
2447
|
+
// either way), imprecise about WHICH failure happened. Two shapes land
|
|
2448
|
+
// here, both meaning "no confirmation this reached the server":
|
|
2449
|
+
// (a) a genuine promise rejection that isn't the timer (an immediate
|
|
2450
|
+
// protocol error) — reaches the `catch` below.
|
|
2451
|
+
// (b) `safeSendRequest`/`safeSendNotification` SWALLOW a stream error
|
|
2452
|
+
// (EPIPE, disposed connection — `isStreamError`) and RESOLVE
|
|
2453
|
+
// instead of rejecting, so the `catch` never runs at all. A real
|
|
2454
|
+
// `shutdown` reply is `null`, never `undefined`, and a real `exit`
|
|
2455
|
+
// notify send returns `true`; `undefined`/`false` is the swallow
|
|
2456
|
+
// case. Without this, a coded EPIPE reported `shutdownOutcome:
|
|
2457
|
+
// "graceful"` — nothing was delivered, but the log called it clean.
|
|
2458
|
+
let shutdownRequestUndelivered = false;
|
|
2459
|
+
let exitNotifyUndelivered = false;
|
|
2460
|
+
// #1620: the graceful handshake is BEST-EFFORT and the teardown is not. A
|
|
2461
|
+
// teardown path must not depend on the health of the thing it is tearing
|
|
2462
|
+
// down — the breaker fires exactly when the server is unresponsive. Keep the
|
|
2463
|
+
// handshake inside the `try` and every irreversible teardown step in the
|
|
2464
|
+
// `finally`, so disposal, the record, the deregistration, and the kill run no
|
|
2465
|
+
// matter how the two writes behave, and so a future added await here cannot
|
|
2466
|
+
// reintroduce the class.
|
|
2467
|
+
try {
|
|
2468
|
+
if (!options.fast) {
|
|
2469
|
+
try {
|
|
2470
|
+
const shutdownAck = await withTimeout(safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS);
|
|
2471
|
+
if (shutdownAck === undefined)
|
|
2472
|
+
shutdownRequestUndelivered = true;
|
|
2473
|
+
}
|
|
2474
|
+
catch (err) {
|
|
2475
|
+
/* ignore — proceed to exit/kill so shutdown cannot hang the session */
|
|
2476
|
+
if (isShutdownTimeoutError(err, SHUTDOWN_REQUEST_TIMEOUT_MS)) {
|
|
2477
|
+
shutdownRequestTimedOut = true;
|
|
2478
|
+
}
|
|
2479
|
+
else {
|
|
2480
|
+
shutdownRequestUndelivered = true;
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
try {
|
|
2484
|
+
const exitSent = await withTimeout(safeSendNotification(state.connection, "exit", {}), EXIT_NOTIFY_TIMEOUT_MS);
|
|
2485
|
+
if (exitSent === false)
|
|
2486
|
+
exitNotifyUndelivered = true;
|
|
2487
|
+
}
|
|
2488
|
+
catch (err) {
|
|
2489
|
+
/* ignore — same reason as the request above */
|
|
2490
|
+
if (isShutdownTimeoutError(err, EXIT_NOTIFY_TIMEOUT_MS)) {
|
|
2491
|
+
exitNotifyTimedOut = true;
|
|
2492
|
+
}
|
|
2493
|
+
else {
|
|
2494
|
+
exitNotifyUndelivered = true;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
1229
2497
|
}
|
|
1230
2498
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
type: "phase",
|
|
1235
|
-
phase: "lsp_client_shutdown",
|
|
1236
|
-
filePath: state.root,
|
|
1237
|
-
durationMs: Date.now() - shutdownStart,
|
|
1238
|
-
metadata: {
|
|
1239
|
-
serverId: state.serverId,
|
|
1240
|
-
pid,
|
|
1241
|
-
fast: !!options.fast,
|
|
1242
|
-
processExiting: !!options.processExiting,
|
|
1243
|
-
shutdownRequestTimedOut,
|
|
1244
|
-
},
|
|
1245
|
-
});
|
|
1246
|
-
// #449/#472: deregister this LSP child from the instance registry. Fire-
|
|
1247
|
-
// and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
|
|
1248
|
-
// including the `processExiting` path where the event loop is closing
|
|
1249
|
-
// (#234 forbids spawning here, but a plain fs write/rename is fine; even
|
|
1250
|
-
// so, we don't await it to keep this teardown path as fast as before).
|
|
1251
|
-
void removeLspChild(pid).catch((err) => {
|
|
2499
|
+
finally {
|
|
2500
|
+
disposeClientConnection(state);
|
|
2501
|
+
const pid = state.lspProcess.pid;
|
|
1252
2502
|
logLatency({
|
|
1253
2503
|
type: "phase",
|
|
1254
|
-
phase: "
|
|
1255
|
-
filePath:
|
|
1256
|
-
durationMs:
|
|
1257
|
-
metadata: {
|
|
2504
|
+
phase: "lsp_client_shutdown",
|
|
2505
|
+
filePath: state.root,
|
|
2506
|
+
durationMs: Date.now() - shutdownStart,
|
|
2507
|
+
metadata: {
|
|
2508
|
+
serverId: state.serverId,
|
|
2509
|
+
pid,
|
|
2510
|
+
fast: !!options.fast,
|
|
2511
|
+
processExiting: !!options.processExiting,
|
|
2512
|
+
shutdownRequestTimedOut,
|
|
2513
|
+
// #1620: `shutdownRequestTimedOut` covered only the request half, so
|
|
2514
|
+
// a hung exit notify was indistinguishable from a clean exit — and
|
|
2515
|
+
// pre-fix no record was emitted at all. Report both halves plus one
|
|
2516
|
+
// rolled-up verdict, so a forced teardown is countable from the log.
|
|
2517
|
+
exitNotifyTimedOut,
|
|
2518
|
+
// #1620 residual 1 / residual-review F2: the *Undelivered pair covers
|
|
2519
|
+
// the failure the *TimedOut pair used to also claim — a rejection
|
|
2520
|
+
// that is NOT the timer winning, OR (the F2 gap) a swallowed stream
|
|
2521
|
+
// error that `safeSendRequest`/`safeSendNotification` resolved
|
|
2522
|
+
// instead of rejecting, so no exception ever reached the catches
|
|
2523
|
+
// above. Both still roll into shutdownOutcome "forced" below — an
|
|
2524
|
+
// undelivered write is never reported as "graceful".
|
|
2525
|
+
shutdownRequestUndelivered,
|
|
2526
|
+
exitNotifyUndelivered,
|
|
2527
|
+
shutdownOutcome: options.fast
|
|
2528
|
+
? "fast"
|
|
2529
|
+
: shutdownRequestTimedOut ||
|
|
2530
|
+
exitNotifyTimedOut ||
|
|
2531
|
+
shutdownRequestUndelivered ||
|
|
2532
|
+
exitNotifyUndelivered
|
|
2533
|
+
? "forced"
|
|
2534
|
+
: "graceful",
|
|
2535
|
+
},
|
|
1258
2536
|
});
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
2537
|
+
// #449/#472: deregister this LSP child from the instance registry. Fire-
|
|
2538
|
+
// and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
|
|
2539
|
+
// including the `processExiting` path where the event loop is closing
|
|
2540
|
+
// (#234 forbids spawning here, but a plain fs write/rename is fine; even
|
|
2541
|
+
// so, we don't await it to keep this teardown path as fast as before).
|
|
2542
|
+
// #1724: pass this spawn's marker so removeLspChild can guard against a
|
|
2543
|
+
// pid-recycling window (a NEW child claiming this exact pid before this
|
|
2544
|
+
// fire-and-forget write lands) rather than removing whichever child
|
|
2545
|
+
// currently sits at this pid.
|
|
2546
|
+
void removeLspChild(pid, extractSpawnMarker(state.lspProcess.args)).catch((err) => {
|
|
2547
|
+
logLatency({
|
|
2548
|
+
type: "phase",
|
|
2549
|
+
phase: "lsp_registry_write_failed",
|
|
2550
|
+
filePath: "",
|
|
2551
|
+
durationMs: 0,
|
|
2552
|
+
metadata: { op: "remove", pid, error: String(err) },
|
|
2553
|
+
});
|
|
2554
|
+
});
|
|
2555
|
+
// On Windows, killing the direct child first can orphan grandchildren before
|
|
2556
|
+
// taskkill can traverse the tree. Kill the full tree first and wait briefly.
|
|
2557
|
+
// Safe and idempotent on an already-exited child: killProcessTree returns
|
|
2558
|
+
// early on an observed exit rather than signalling a possibly-recycled pid.
|
|
2559
|
+
await killProcessTree(state.lspProcess.process, pid, options);
|
|
2560
|
+
}
|
|
1263
2561
|
}
|
|
1264
2562
|
/**
|
|
1265
2563
|
* Translate a caller-supplied (UTF-16) `(line, character)` into the position the
|
|
@@ -1308,7 +2606,30 @@ signal = getAmbientAbortSignal()) {
|
|
|
1308
2606
|
const requestVersion = normalizedPath !== undefined
|
|
1309
2607
|
? state.documentVersions.get(normalizedPath)
|
|
1310
2608
|
: undefined;
|
|
1311
|
-
|
|
2609
|
+
// #1716: captured OUTSIDE withTimeout, mirroring #1713's pull-diagnostic
|
|
2610
|
+
// fix (`pullDiagnosticSource`), so a timeout's catch below still holds a
|
|
2611
|
+
// live handle on the request for the late-answer watch. `withTimeout`
|
|
2612
|
+
// only races it against a timer — the request keeps running server-side
|
|
2613
|
+
// either way, and without this handle an abandoned answer would settle
|
|
2614
|
+
// into the void with no way to observe it.
|
|
2615
|
+
const requestStartedAt = Date.now();
|
|
2616
|
+
const requestPromise = safeSendRequest(state.connection, method, params, signal);
|
|
2617
|
+
const result = (await withTimeout(requestPromise, timeoutMs).catch((err) => {
|
|
2618
|
+
if (isNavTimeoutError(err, timeoutMs)) {
|
|
2619
|
+
recordNavTimeoutTelemetry({
|
|
2620
|
+
method,
|
|
2621
|
+
scope: normalizedPath,
|
|
2622
|
+
budgetMs: timeoutMs,
|
|
2623
|
+
elapsedMs: Date.now() - requestStartedAt,
|
|
2624
|
+
});
|
|
2625
|
+
armNavLateAnswerTelemetry({
|
|
2626
|
+
requestPromise,
|
|
2627
|
+
method,
|
|
2628
|
+
scope: normalizedPath,
|
|
2629
|
+
requestStartedAt,
|
|
2630
|
+
});
|
|
2631
|
+
return undefined;
|
|
2632
|
+
}
|
|
1312
2633
|
if (err instanceof Error && err.message.startsWith("Timeout after")) {
|
|
1313
2634
|
return undefined;
|
|
1314
2635
|
}
|
|
@@ -1326,6 +2647,109 @@ signal = getAmbientAbortSignal()) {
|
|
|
1326
2647
|
}
|
|
1327
2648
|
return result;
|
|
1328
2649
|
}
|
|
2650
|
+
/**
|
|
2651
|
+
* #1716: `withTimeout` rejects with this EXACT message (`clients/deadline-
|
|
2652
|
+
* utils.ts`) only when its own timer wins the race — mirrors #1713's
|
|
2653
|
+
* `isPullTimeoutError`. Only the timer-won case is a "the answer might still
|
|
2654
|
+
* be coming" situation worth a late-answer watch; a request that already
|
|
2655
|
+
* failed fast (or a stale-message coincidence) has nothing further to
|
|
2656
|
+
* observe. `navRequest`'s own behavior still falls back to the looser
|
|
2657
|
+
* `startsWith` check afterward, unchanged, so this stricter check only gates
|
|
2658
|
+
* telemetry — never the existing timeout-handling behavior.
|
|
2659
|
+
*/
|
|
2660
|
+
function isNavTimeoutError(err, timeoutMs) {
|
|
2661
|
+
return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* #1620 residual 1: `withTimeout` rejects with this EXACT message only when
|
|
2665
|
+
* its own timer wins the race — mirrors #1713's `isPullTimeoutError` and
|
|
2666
|
+
* #1716's `isNavTimeoutError`. `clientShutdown`'s two catches previously
|
|
2667
|
+
* treated ANY rejection (a real timeout OR an immediate protocol error) as
|
|
2668
|
+
* "timed out"; honest for the rolled-up `shutdownOutcome` (both are
|
|
2669
|
+
* "forced"), imprecise for the per-field flag a log reader keys on.
|
|
2670
|
+
*/
|
|
2671
|
+
function isShutdownTimeoutError(err, timeoutMs) {
|
|
2672
|
+
return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
|
|
2673
|
+
}
|
|
2674
|
+
// #1716: subject/scope used when a nav request has no `staleCheckPath` (e.g.
|
|
2675
|
+
// workspaceSymbol, call-hierarchy follow-ups) — mirrors WORKSPACE_PULL_SCOPE
|
|
2676
|
+
// above for the same reason: telemetry identity needs a stable placeholder
|
|
2677
|
+
// where there is no real path.
|
|
2678
|
+
const NAV_REQUEST_NO_PATH_SCOPE = "*no-path*";
|
|
2679
|
+
/**
|
|
2680
|
+
* #1716: `navRequest` is the highest-volume LSP call site — hover,
|
|
2681
|
+
* definition, references, signatureHelp, documentSymbol, workspace/symbol,
|
|
2682
|
+
* call hierarchy all route through it, often several times per turn. A stuck
|
|
2683
|
+
* server can storm timeouts across many methods and files far faster than
|
|
2684
|
+
* the pull-diagnostic path #1713 fixed, so a per-event latency.log write
|
|
2685
|
+
* (that fix's shape) would itself become the flood. Every timeout is still
|
|
2686
|
+
* counted exactly, via the degradation ledger (bounded, in-memory, no I/O),
|
|
2687
|
+
* but only the RISING EDGE — the first occurrence per (method, file) this
|
|
2688
|
+
* session — also pays for a detailed log write. `incrementDegradationCount`'s
|
|
2689
|
+
* return value (`true` on the first occurrence for a kind/subject pair) is
|
|
2690
|
+
* reused as that rising-edge gate instead of a second hand-rolled latch: it
|
|
2691
|
+
* already re-arms at session_start via `resetDegradationLedger`, so this
|
|
2692
|
+
* adds zero new module state. Never throws: telemetry must not perturb a
|
|
2693
|
+
* path that has already decided to return `undefined` regardless.
|
|
2694
|
+
*/
|
|
2695
|
+
function recordNavTimeoutTelemetry(args) {
|
|
2696
|
+
// #1743: `risingEdgePer: "identity"` is the same gate this site hand-rolled
|
|
2697
|
+
// off `incrementDegradationCount`'s return value, now expressed as an
|
|
2698
|
+
// option. The ledger still counts every timeout exactly, per (method, file).
|
|
2699
|
+
emitBounded("lsp_nav_request_timeout", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
|
|
2700
|
+
type: "phase",
|
|
2701
|
+
filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
|
|
2702
|
+
durationMs: args.elapsedMs,
|
|
2703
|
+
metadata: { method: args.method, effectiveBudgetMs: args.budgetMs },
|
|
2704
|
+
}, {
|
|
2705
|
+
ledgerKind: "lsp-nav-request-timeout",
|
|
2706
|
+
risingEdgePer: "identity",
|
|
2707
|
+
reason: `timeout budget=${args.budgetMs}ms elapsed=${args.elapsedMs}ms`,
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
/**
|
|
2711
|
+
* #1716: nav-request sibling of #1713's `armLateAnswerTelemetry`. A
|
|
2712
|
+
* telemetry-only continuation on the ALREADY-abandoned request promise —
|
|
2713
|
+
* purely observational, never touches `state`, and attaching `.then` here
|
|
2714
|
+
* creates no new timer, socket, or other handle, so it cannot keep the
|
|
2715
|
+
* process alive a moment longer than the pending request already does.
|
|
2716
|
+
*
|
|
2717
|
+
* Bounded the same way as the timeout record above: every late answer is
|
|
2718
|
+
* counted exactly via the ledger, but only the rising edge per (method,
|
|
2719
|
+
* file) also writes an `lsp_nav_late_answer_discarded` record.
|
|
2720
|
+
*
|
|
2721
|
+
* Nav answers are read-once — unlike diagnostics' persistent pull cache,
|
|
2722
|
+
* there is no stale value this could poison — so this exists purely for
|
|
2723
|
+
* observability: telling a dogfood session "truly hung" apart from
|
|
2724
|
+
* "answered late", the same distinction #1713 made possible for pulls.
|
|
2725
|
+
*/
|
|
2726
|
+
function armNavLateAnswerTelemetry(args) {
|
|
2727
|
+
args.requestPromise.then(() => {
|
|
2728
|
+
try {
|
|
2729
|
+
const elapsedMs = Date.now() - args.requestStartedAt;
|
|
2730
|
+
// #1743: same rising-edge gate as the timeout record above, now
|
|
2731
|
+
// carried by the helper instead of hand-rolled here.
|
|
2732
|
+
emitBounded("lsp_nav_late_answer_discarded", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
|
|
2733
|
+
type: "phase",
|
|
2734
|
+
filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
|
|
2735
|
+
durationMs: elapsedMs,
|
|
2736
|
+
metadata: { method: args.method },
|
|
2737
|
+
}, {
|
|
2738
|
+
ledgerKind: "lsp-nav-late-answer",
|
|
2739
|
+
risingEdgePer: "identity",
|
|
2740
|
+
reason: `late nav answer discarded after ${elapsedMs}ms`,
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
catch {
|
|
2744
|
+
// Telemetry must never break the observed path.
|
|
2745
|
+
}
|
|
2746
|
+
}, () => {
|
|
2747
|
+
// The abandoned request eventually failed rather than answering —
|
|
2748
|
+
// not an "answer discarded" event. Nothing more to record here; this
|
|
2749
|
+
// handler exists so the request's eventual rejection never surfaces
|
|
2750
|
+
// as an unhandled rejection.
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
1329
2753
|
// #1277: cheap liveness round-trip used by the silent-clean gates in
|
|
1330
2754
|
// `index.ts`. `isAlive()`/`checkAlive()` only look at process/connection
|
|
1331
2755
|
// state — a server that accepted the notify write and then wedged (still
|
|
@@ -1409,12 +2833,53 @@ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE
|
|
|
1409
2833
|
state.activeMutationContext = undefined;
|
|
1410
2834
|
}
|
|
1411
2835
|
}
|
|
2836
|
+
// #1412 H1/H2: read-only sibling of runServerCommand for telemetry/identity
|
|
2837
|
+
// probes that must NOT participate in the mutation-acceptance window. Unlike
|
|
2838
|
+
// runServerCommand this never touches serverEditsAllowed, activeMutationDepth,
|
|
2839
|
+
// or activeMutationContext — a probe firing mid-flight must leave a concurrent
|
|
2840
|
+
// real executeCommand's mutation context untouched, and must not itself open
|
|
2841
|
+
// the workspace/applyEdit acceptance window (client.ts's applyEdit handler
|
|
2842
|
+
// gates on serverEditsAllowed > 0). Preserves the allowlist-by-advertisement
|
|
2843
|
+
// invariant. Short PROBE_COMMAND_TIMEOUT_MS backstop — this is a diagnostic
|
|
2844
|
+
// sample, not a mutation, and must never hold anything up for anywhere near
|
|
2845
|
+
// EXECUTE_COMMAND_TIMEOUT_MS.
|
|
2846
|
+
export async function runReadOnlyServerCommand(state, command, args, timeoutMs = PROBE_COMMAND_TIMEOUT_MS) {
|
|
2847
|
+
if (!isClientAlive(state)) {
|
|
2848
|
+
return { executed: false, reason: "lsp client not alive" };
|
|
2849
|
+
}
|
|
2850
|
+
if (!state.advertisedCommands.has(command)) {
|
|
2851
|
+
return {
|
|
2852
|
+
executed: false,
|
|
2853
|
+
reason: `command "${command}" is not advertised by the ${state.serverId} server`,
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
try {
|
|
2857
|
+
const result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
|
|
2858
|
+
command,
|
|
2859
|
+
arguments: args ?? [],
|
|
2860
|
+
}), timeoutMs);
|
|
2861
|
+
return { executed: true, result };
|
|
2862
|
+
}
|
|
2863
|
+
catch (err) {
|
|
2864
|
+
if (err instanceof Error && err.message.startsWith("Timeout after")) {
|
|
2865
|
+
return {
|
|
2866
|
+
executed: false,
|
|
2867
|
+
reason: `workspace/executeCommand timed out after ${timeoutMs}ms`,
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2870
|
+
throw err;
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
1412
2873
|
function validateWorkspaceEditVersions(state, edit) {
|
|
1413
2874
|
for (const change of edit.documentChanges ?? []) {
|
|
1414
|
-
if (typeof change !== "object" ||
|
|
2875
|
+
if (typeof change !== "object" ||
|
|
2876
|
+
change === null ||
|
|
2877
|
+
!("textDocument" in change))
|
|
1415
2878
|
continue;
|
|
1416
2879
|
const textDocument = change.textDocument;
|
|
1417
|
-
if (!textDocument ||
|
|
2880
|
+
if (!textDocument ||
|
|
2881
|
+
typeof textDocument.uri !== "string" ||
|
|
2882
|
+
textDocument.version == null)
|
|
1418
2883
|
continue;
|
|
1419
2884
|
const current = state.documentVersions.get(normalizeMapKey(uriToPath(textDocument.uri)));
|
|
1420
2885
|
if (current === undefined || current !== textDocument.version) {
|
|
@@ -1481,7 +2946,10 @@ async function resolveCodeActionBestEffort(state, action) {
|
|
|
1481
2946
|
return action;
|
|
1482
2947
|
const merged = { ...action, ...resolved };
|
|
1483
2948
|
return merged.edit
|
|
1484
|
-
? {
|
|
2949
|
+
? {
|
|
2950
|
+
...merged,
|
|
2951
|
+
edit: await normalizeClientWorkspaceEdit(state, merged.edit),
|
|
2952
|
+
}
|
|
1485
2953
|
: merged;
|
|
1486
2954
|
}
|
|
1487
2955
|
// --- Client Factory ---
|
|
@@ -1578,10 +3046,21 @@ export async function createLSPClient(options) {
|
|
|
1578
3046
|
// for concurrent waitForDiagnostics calls.
|
|
1579
3047
|
const diagnosticEmitter = new EventEmitter();
|
|
1580
3048
|
diagnosticEmitter.setMaxListeners(50);
|
|
3049
|
+
// SAFETY: three fields below are seeded `undefined as unknown as T` because
|
|
3050
|
+
// they cannot be built here. `workspaceDiagnosticsSupport` and
|
|
3051
|
+
// `operationSupport` are derived from the server's `initialize` result, and
|
|
3052
|
+
// `watchQueue`'s flush closure needs `state` itself. Each is assigned
|
|
3053
|
+
// in this same function before `state` reaches any caller:
|
|
3054
|
+
// `state.watchQueue` on the statement immediately after this literal, and
|
|
3055
|
+
// the two capability fields right after the `initialize` handshake
|
|
3056
|
+
// returns. Reorder that and the fields are genuinely `undefined` at their
|
|
3057
|
+
// declared non-optional types.
|
|
1581
3058
|
const state = {
|
|
1582
3059
|
isConnected: true,
|
|
1583
3060
|
isDestroyed: false,
|
|
1584
3061
|
shutdownRequested: false,
|
|
3062
|
+
shutdownPromise: undefined,
|
|
3063
|
+
shutdownOptions: undefined,
|
|
1585
3064
|
exitedAt: undefined,
|
|
1586
3065
|
connectionDisposed: false,
|
|
1587
3066
|
lastError: undefined,
|
|
@@ -1592,23 +3071,34 @@ export async function createLSPClient(options) {
|
|
|
1592
3071
|
documentPullDiagnosticTimestamps: new Map(),
|
|
1593
3072
|
pullFailureHistory: [],
|
|
1594
3073
|
pendingDiagnostics: new Map(),
|
|
3074
|
+
diagnosticPublicationCounts: new Map(),
|
|
3075
|
+
documentOpenedAt: new Map(),
|
|
1595
3076
|
diagnosticEmitter,
|
|
1596
3077
|
diagnosticsVersion: 0,
|
|
3078
|
+
diagnosticsVersionsByPath: new Map(),
|
|
1597
3079
|
documentVersions: new Map(),
|
|
1598
3080
|
diagnosticDocVersions: new Map(),
|
|
1599
3081
|
documentContentHashes: new Map(),
|
|
1600
3082
|
diagnosticBindings: new Map(),
|
|
1601
3083
|
pullResultIds: new Map(),
|
|
3084
|
+
documentPullDiagnosticsBySource: new Map(),
|
|
3085
|
+
pullGenerations: new Map(),
|
|
3086
|
+
pullRequestSequences: new Map(),
|
|
1602
3087
|
workspacePullResultCache: new Map(),
|
|
1603
3088
|
openDocuments: new Set(),
|
|
1604
3089
|
closedDocuments: new Set(),
|
|
1605
3090
|
openDocumentUris: new Map(),
|
|
1606
3091
|
pendingOpens: new Set(),
|
|
3092
|
+
projectIdentityProbedFiles: new Set(),
|
|
1607
3093
|
// these are filled in after initialize — cast to avoid two-phase init
|
|
1608
|
-
workspaceDiagnosticsSupport:
|
|
3094
|
+
workspaceDiagnosticsSupport:
|
|
3095
|
+
// SAFETY: initialize fills this capability before any workspace diagnostic request.
|
|
3096
|
+
undefined,
|
|
3097
|
+
// SAFETY: initialize fills this capability before operation dispatch.
|
|
1609
3098
|
operationSupport: undefined,
|
|
1610
3099
|
staticDiagnosticsMode: "push-only",
|
|
1611
3100
|
positionEncoding: "utf-16",
|
|
3101
|
+
syncKind: TEXT_DOCUMENT_SYNC_KIND_FULL,
|
|
1612
3102
|
dynamicRegistrations: new Map(),
|
|
1613
3103
|
advertisedCommands: new Set(),
|
|
1614
3104
|
serverEditsAllowed: 0,
|
|
@@ -1618,6 +3108,7 @@ export async function createLSPClient(options) {
|
|
|
1618
3108
|
root,
|
|
1619
3109
|
lspProcess,
|
|
1620
3110
|
// two-phase: the flush closure needs `state` (below)
|
|
3111
|
+
// SAFETY: state construction completes before the flush closure can run.
|
|
1621
3112
|
watchQueue: undefined,
|
|
1622
3113
|
};
|
|
1623
3114
|
// #271: batch per-file workspace/didChangeWatchedFiles into one notification
|
|
@@ -1650,7 +3141,7 @@ export async function createLSPClient(options) {
|
|
|
1650
3141
|
// A child registered above (recordLspChild) but never reaching a healthy
|
|
1651
3142
|
// createLSPClient return must still be deregistered here — otherwise the
|
|
1652
3143
|
// registry keeps a stale entry for a process we just killed.
|
|
1653
|
-
void removeLspChild(pid).catch((err) => {
|
|
3144
|
+
void removeLspChild(pid, extractSpawnMarker(lspProcess.args)).catch((err) => {
|
|
1654
3145
|
// best-effort — a stale registry entry is harmless (the reaper's
|
|
1655
3146
|
// liveness check will find it dead on the next sweep regardless)
|
|
1656
3147
|
logLatency({
|
|
@@ -1711,6 +3202,7 @@ export async function createLSPClient(options) {
|
|
|
1711
3202
|
detectWorkspaceDiagnosticsSupport(initResult);
|
|
1712
3203
|
state.operationSupport = detectOperationSupport(initResult);
|
|
1713
3204
|
state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
|
|
3205
|
+
state.syncKind = negotiateSyncKind(initResult?.capabilities);
|
|
1714
3206
|
state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
|
|
1715
3207
|
{}).sort((a, b) => a.localeCompare(b));
|
|
1716
3208
|
for (const cmd of detectExecuteCommands(initResult)) {
|
|
@@ -1752,6 +3244,9 @@ export async function createLSPClient(options) {
|
|
|
1752
3244
|
async change(filePath, content) {
|
|
1753
3245
|
return handleNotifyChange(state, filePath, content);
|
|
1754
3246
|
},
|
|
3247
|
+
watchedFileChange(filePath, type) {
|
|
3248
|
+
handleNotifyExternalChange(state, filePath, type);
|
|
3249
|
+
},
|
|
1755
3250
|
},
|
|
1756
3251
|
getDiagnostics(filePath) {
|
|
1757
3252
|
return getMergedDiagnosticsForPath(state, normalizeMapKey(filePath));
|
|
@@ -1759,6 +3254,9 @@ export async function createLSPClient(options) {
|
|
|
1759
3254
|
getDiagnosticBinding(filePath) {
|
|
1760
3255
|
return state.diagnosticBindings.get(normalizeMapKey(filePath));
|
|
1761
3256
|
},
|
|
3257
|
+
getDiagnosticsVersionForPath(filePath) {
|
|
3258
|
+
return diagnosticsVersionForPath(state, normalizeMapKey(filePath));
|
|
3259
|
+
},
|
|
1762
3260
|
getAllDiagnostics() {
|
|
1763
3261
|
const result = new Map();
|
|
1764
3262
|
const keys = new Set([
|
|
@@ -1775,10 +3273,12 @@ export async function createLSPClient(options) {
|
|
|
1775
3273
|
return result;
|
|
1776
3274
|
},
|
|
1777
3275
|
getTrackedDiagnosticPaths() {
|
|
1778
|
-
return [
|
|
3276
|
+
return [
|
|
3277
|
+
...new Set([
|
|
1779
3278
|
...state.pushDiagnostics.keys(),
|
|
1780
3279
|
...state.documentPullDiagnostics.keys(),
|
|
1781
|
-
])
|
|
3280
|
+
]),
|
|
3281
|
+
].map((filePath) => process.platform === "win32" ? filePath.replace(/\//g, "\\") : filePath);
|
|
1782
3282
|
},
|
|
1783
3283
|
pruneDiagnostics(predicate) {
|
|
1784
3284
|
let removed = 0;
|
|
@@ -1817,6 +3317,9 @@ export async function createLSPClient(options) {
|
|
|
1817
3317
|
async executeCommand(command, args, mutationContext) {
|
|
1818
3318
|
return runServerCommand(state, command, args, EXECUTE_COMMAND_TIMEOUT_MS, mutationContext);
|
|
1819
3319
|
},
|
|
3320
|
+
async executeReadOnlyCommand(command, args) {
|
|
3321
|
+
return runReadOnlyServerCommand(state, command, args);
|
|
3322
|
+
},
|
|
1820
3323
|
get diagnosticsVersion() {
|
|
1821
3324
|
return state.diagnosticsVersion;
|
|
1822
3325
|
},
|
|
@@ -1982,14 +3485,25 @@ export async function createLSPClient(options) {
|
|
|
1982
3485
|
};
|
|
1983
3486
|
}
|
|
1984
3487
|
// Helper to safely send notifications - catches stream destruction
|
|
3488
|
+
/**
|
|
3489
|
+
* Returns `true` once the notification was actually handed to the transport,
|
|
3490
|
+
* `false` when a stream error was swallowed (connection error handlers will
|
|
3491
|
+
* update state separately). #1669 review F7: callers that mirror what they
|
|
3492
|
+
* just told the server locally (`recordSentContent`) MUST gate on this
|
|
3493
|
+
* return value — recording a send that never left the process would desync
|
|
3494
|
+
* the mirror from what the server actually has, with nothing to self-heal
|
|
3495
|
+
* it (the next Incremental range would be computed against content the
|
|
3496
|
+
* server never saw).
|
|
3497
|
+
*/
|
|
1985
3498
|
async function safeSendNotification(connection, method, params) {
|
|
1986
3499
|
try {
|
|
1987
3500
|
await connection.sendNotification(method, params);
|
|
3501
|
+
return true;
|
|
1988
3502
|
}
|
|
1989
3503
|
catch (err) {
|
|
1990
3504
|
if (isStreamError(err)) {
|
|
1991
3505
|
// Silently ignore - stream was destroyed, connection error handlers will update state
|
|
1992
|
-
return;
|
|
3506
|
+
return false;
|
|
1993
3507
|
}
|
|
1994
3508
|
throw err;
|
|
1995
3509
|
}
|
|
@@ -2001,7 +3515,7 @@ async function safeSendRequest(connection, method, params,
|
|
|
2001
3515
|
// vscode-jsonrpc's CancellationToken → an LSP `$/cancelRequest` notification,
|
|
2002
3516
|
// so a server stops computing a result the agent has already abandoned (#238
|
|
2003
3517
|
// Item 1). The rejection that follows is swallowed (treated as `undefined`).
|
|
2004
|
-
signal) {
|
|
3518
|
+
signal, settlement) {
|
|
2005
3519
|
// Already abandoned before we even sent — don't bother the server.
|
|
2006
3520
|
if (signal?.aborted)
|
|
2007
3521
|
return undefined;
|
|
@@ -2028,9 +3542,13 @@ signal) {
|
|
|
2028
3542
|
return (await send());
|
|
2029
3543
|
}
|
|
2030
3544
|
catch (err) {
|
|
2031
|
-
if (
|
|
2032
|
-
|
|
2033
|
-
|
|
3545
|
+
if (isCancellationError(err)) {
|
|
3546
|
+
if (settlement)
|
|
3547
|
+
settlement.cancelled = true;
|
|
3548
|
+
return undefined;
|
|
3549
|
+
}
|
|
3550
|
+
if (isStreamError(err)) {
|
|
3551
|
+
// Stream destroyed; connection handlers update client state separately.
|
|
2034
3552
|
return undefined;
|
|
2035
3553
|
}
|
|
2036
3554
|
if (isContentModifiedError(err)) {
|