@herbertgao/pi-extensions 2026.8.6 → 2026.8.8
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 +8 -6
- 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 +320 -22
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +79 -17
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +27 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +66 -17
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
- package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
- package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
- package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +17 -13
- package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
- package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
- package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
- package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
- package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
- package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +21 -6
- package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +20 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +5 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +174 -100
- 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 +5 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +10 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +5 -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 +140 -97
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +63 -28
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +86 -38
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +76 -9
- package/node_modules/@narumitw/pi-btw/README.md +120 -111
- package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
- package/node_modules/@narumitw/pi-btw/package.json +10 -6
- package/node_modules/@narumitw/pi-btw/src/btw.ts +36 -40
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
- package/node_modules/@narumitw/pi-btw/src/menu.ts +45 -2
- package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
- package/node_modules/pi-lens/CHANGELOG.md +999 -0
- package/node_modules/pi-lens/README.md +117 -3
- package/node_modules/pi-lens/config/ruff/core.toml +42 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +126 -13
- package/node_modules/pi-lens/dist/clients/agent-nudge.js +67 -11
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
- package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +381 -13
- package/node_modules/pi-lens/dist/clients/biome-client.js +61 -30
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
- package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +129 -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 +206 -0
- package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
- package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
- package/node_modules/pi-lens/dist/clients/cascade-format.js +20 -1
- package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
- package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
- package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +123 -31
- package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
- package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +84 -14
- package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +25 -3
- package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
- package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +147 -33
- package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +52 -2
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +256 -88
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
- 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/hadolint.js +9 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +60 -53
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +25 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
- 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 +17 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +32 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +47 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +14 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +127 -28
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +275 -13
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +21 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +734 -64
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +188 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +9 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +21 -10
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
- package/node_modules/pi-lens/dist/clients/extension-log.js +22 -1
- package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +316 -0
- package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
- package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
- package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
- package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
- package/node_modules/pi-lens/dist/clients/git-guard.js +31 -0
- package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +109 -9
- package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
- package/node_modules/pi-lens/dist/clients/host-ports.js +8 -0
- package/node_modules/pi-lens/dist/clients/installer/index.js +1114 -100
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
- package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +87 -8
- package/node_modules/pi-lens/dist/clients/knip-client.js +310 -10
- package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +9 -2
- package/node_modules/pi-lens/dist/clients/latency-logger.js +440 -11
- package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
- package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +128 -8
- package/node_modules/pi-lens/dist/clients/lsp/client.js +1660 -164
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +83 -1
- package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
- package/node_modules/pi-lens/dist/clients/lsp/index.js +2423 -151
- package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
- package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
- package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
- package/node_modules/pi-lens/dist/clients/lsp/server.js +277 -16
- package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +79 -3
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +43 -0
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
- package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
- package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
- package/node_modules/pi-lens/dist/clients/mcp/session.js +10 -2
- package/node_modules/pi-lens/dist/clients/module-report.js +22 -1
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
- package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
- package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
- package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +154 -33
- package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +84 -14
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +23 -9
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
- package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
- 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 +8 -3
- package/node_modules/pi-lens/dist/clients/read-guard.js +253 -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/builder.js +194 -31
- package/node_modules/pi-lens/dist/clients/review-graph/query.js +52 -4
- package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +96 -7
- package/node_modules/pi-lens/dist/clients/runtime-context.js +8 -1
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +352 -45
- package/node_modules/pi-lens/dist/clients/runtime-session.js +373 -9
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +212 -79
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +620 -67
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
- package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
- package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +157 -24
- package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
- package/node_modules/pi-lens/dist/clients/sg-runner.js +116 -22
- package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
- package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
- package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
- package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
- package/node_modules/pi-lens/dist/clients/startup-timing.js +19 -1
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +151 -16
- package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
- package/node_modules/pi-lens/dist/clients/tool-policy.js +125 -20
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
- package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
- package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
- package/node_modules/pi-lens/dist/index.js +61407 -48577
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +476 -57
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +132 -43
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
- package/node_modules/pi-lens/docs/agent-guide.md +6 -1
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +515 -506
- package/node_modules/pi-lens/docs/dispositions.md +21 -0
- package/node_modules/pi-lens/docs/features.md +19 -5
- package/node_modules/pi-lens/docs/globalconfig.md +3 -0
- package/node_modules/pi-lens/docs/language-coverage.md +71 -5
- package/node_modules/pi-lens/docs/mcp.md +2 -2
- package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
- package/node_modules/pi-lens/docs/settings.md +19 -1
- package/node_modules/pi-lens/docs/usage.md +1 -0
- package/node_modules/pi-lens/package.json +27 -10
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-js-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/ts-void-zero-snapshot.yml +23 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-object-param-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +15 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-compile-call-test.yml +29 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +2 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +2 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +57 -12
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +22 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +23 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +33 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +34 -8
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +6 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +53 -1
- package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
- package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
- package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
- package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
- package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
- package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
- package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +2 -0
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +80 -3
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +41 -0
- package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +36 -0
- package/node_modules/pi-mcp-adapter/README.md +47 -5
- package/node_modules/pi-mcp-adapter/cli.js +163 -1
- package/node_modules/pi-mcp-adapter/commands.ts +128 -22
- package/node_modules/pi-mcp-adapter/config.ts +25 -7
- package/node_modules/pi-mcp-adapter/direct-tools.ts +5 -4
- package/node_modules/pi-mcp-adapter/index.ts +219 -32
- package/node_modules/pi-mcp-adapter/init.ts +36 -4
- package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
- package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
- package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
- package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
- package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +32 -21
- package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
- package/node_modules/pi-mcp-adapter/package.json +3 -1
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +31 -37
- package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
- package/node_modules/pi-mcp-adapter/server-manager.ts +140 -2
- package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
- package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
- package/node_modules/pi-mcp-adapter/types.ts +2 -0
- package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
- package/node_modules/pi-mcp-adapter/utils.ts +70 -0
- package/node_modules/pi-web-access/CHANGELOG.md +37 -0
- package/node_modules/pi-web-access/README.md +32 -13
- package/node_modules/pi-web-access/anysearch.ts +4 -2
- package/node_modules/pi-web-access/brave.ts +16 -4
- package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
- package/node_modules/pi-web-access/curator-page.ts +6 -3
- package/node_modules/pi-web-access/curator-server.ts +4 -1
- package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
- package/node_modules/pi-web-access/exa.ts +18 -7
- package/node_modules/pi-web-access/extract.ts +40 -5
- package/node_modules/pi-web-access/firecrawl.ts +18 -2
- package/node_modules/pi-web-access/gemini-search.ts +16 -4
- package/node_modules/pi-web-access/github-extract.ts +65 -36
- package/node_modules/pi-web-access/index.ts +60 -61
- package/node_modules/pi-web-access/openai-search.ts +17 -6
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/page-query.ts +4 -2
- package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
- package/node_modules/pi-web-access/parallel.ts +2 -2
- package/node_modules/pi-web-access/pdf-extract.ts +18 -5
- 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/ssrf-protection.ts +12 -2
- package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
- package/node_modules/pi-web-access/summary-review.ts +44 -22
- package/node_modules/pi-web-access/tavily.ts +16 -4
- package/node_modules/pi-web-access/utils.ts +87 -0
- package/node_modules/pi-web-access/valyu.ts +199 -0
- package/package.json +14 -14
|
@@ -7,27 +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
|
-
import { probeTsserverProjectIdentity } from "./tsserver-sync.js";
|
|
28
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";
|
|
29
33
|
import { getStrategy } from "./wait-policy/index.js";
|
|
30
34
|
import { WatchedFilesQueue } from "./watch-queue.js";
|
|
35
|
+
import { clearAllWorkspaceDiagnosticsCaches, clearWorkspaceDiagnosticsCacheAtAndAbove, } from "./workspace-diagnostics-cache.js";
|
|
31
36
|
// Opt-in publishDiagnostics trace (PILENS_PUB_DEBUG=1) — read once, negligible
|
|
32
37
|
// hot-path cost. Surfaces each server's publish behavior (version + count) to
|
|
33
38
|
// diagnose the clean-file affirmative-signal question (#240): which servers
|
|
@@ -48,6 +53,8 @@ const PUB_DEBUG = Boolean(process.env.PILENS_PUB_DEBUG);
|
|
|
48
53
|
* argument, without new server-specific code.
|
|
49
54
|
*/
|
|
50
55
|
function extractSpawnMarker(args) {
|
|
56
|
+
if (!args)
|
|
57
|
+
return undefined;
|
|
51
58
|
const tmpDir = os.tmpdir();
|
|
52
59
|
for (let i = 0; i < args.length - 1; i++) {
|
|
53
60
|
const flag = args[i];
|
|
@@ -126,7 +133,31 @@ const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_
|
|
|
126
133
|
// the diagnostics flush. On timeout the request is treated as `unavailable`, which
|
|
127
134
|
// (per #240) is NOT read as clean and falls through to the bounded push backstop.
|
|
128
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;
|
|
129
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);
|
|
130
161
|
// #1277: cheap liveness round-trip for the silent-clean gates (`index.ts`).
|
|
131
162
|
// Those gates convert a diagnostics-wait timeout into a confirmed-clean
|
|
132
163
|
// result from a STATIC capability classification (`silentOnClean`) alone —
|
|
@@ -146,6 +177,36 @@ const LIVENESS_PING_QUERY = "__pi_lens_liveness_ping__";
|
|
|
146
177
|
// cheaply rebuilt by the next full pull, the worst case is just one extra full
|
|
147
178
|
// (non-`unchanged`) report per affected file.
|
|
148
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
|
+
}
|
|
149
210
|
// Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
|
|
150
211
|
// (30s): the command is mutating and legitimately long-running (a real server
|
|
151
212
|
// refactor / organize-imports), so this must not truncate valid work — it only
|
|
@@ -414,10 +475,15 @@ function normalizeLspDiagnostic(diagnostic) {
|
|
|
414
475
|
function normalizeLspDiagnostics(diagnostics) {
|
|
415
476
|
return diagnostics.map(normalizeLspDiagnostic);
|
|
416
477
|
}
|
|
417
|
-
|
|
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) {
|
|
418
484
|
const merged = [];
|
|
419
485
|
const seen = new Set();
|
|
420
|
-
for (const diagnostic of
|
|
486
|
+
for (const diagnostic of lists.flatMap((list) => list ?? [])) {
|
|
421
487
|
const key = [
|
|
422
488
|
diagnostic.range.start.line,
|
|
423
489
|
diagnostic.range.start.character,
|
|
@@ -435,14 +501,185 @@ function mergeDiagnosticLists(push, pull) {
|
|
|
435
501
|
return merged;
|
|
436
502
|
}
|
|
437
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.
|
|
438
510
|
const legacy = state;
|
|
439
511
|
return mergeDiagnosticLists(state.pushDiagnostics?.get(normalizedPath) ??
|
|
440
512
|
legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath));
|
|
441
513
|
}
|
|
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
|
+
}
|
|
442
675
|
/** Exported for tests: the quiet-window timer cancel on clear/resync is the
|
|
443
676
|
* headline #1412 safety property (a stale versionless publication must never
|
|
444
677
|
* land after the document content changed). */
|
|
445
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.
|
|
446
683
|
const legacy = state;
|
|
447
684
|
state.pushDiagnostics?.delete(normalizedPath);
|
|
448
685
|
const pending = state.pendingDiagnostics?.get(normalizedPath);
|
|
@@ -458,37 +695,96 @@ export function clearDiagnosticsForPath(state, normalizedPath) {
|
|
|
458
695
|
// `documentContentHashes` record is intentionally retained: it describes what
|
|
459
696
|
// we sent, which the NEXT publish for that version still needs to bind to.)
|
|
460
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);
|
|
461
704
|
// #1104: a resync invalidates any `unchanged`-report basis too — the next
|
|
462
705
|
// pull must not inherit a resultId/contentHash computed against the
|
|
463
706
|
// content this resync just replaced.
|
|
464
|
-
|
|
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);
|
|
465
725
|
state.workspacePullResultCache?.delete(normalizedPath);
|
|
466
726
|
legacy.diagnostics?.delete(normalizedPath);
|
|
467
727
|
legacy.diagnosticTimestamps?.delete(normalizedPath);
|
|
468
728
|
}
|
|
469
|
-
|
|
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) {
|
|
470
761
|
if (state.serverId !== "typescript")
|
|
471
762
|
return;
|
|
472
763
|
const diagnostics = state.documentPullDiagnostics.get(normalizedPath) ?? [];
|
|
473
764
|
const elapsedSinceDidOpenMs = Math.max(0, Date.now() - (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
|
|
474
|
-
const diagnosticCodes = [
|
|
765
|
+
const diagnosticCodes = [
|
|
766
|
+
...new Set(diagnostics
|
|
475
767
|
.map((diagnostic) => diagnostic.code)
|
|
476
768
|
.filter((code) => code !== undefined)
|
|
477
|
-
.map(String))
|
|
769
|
+
.map(String)),
|
|
770
|
+
].slice(0, 8);
|
|
771
|
+
const trackedVersion = state.diagnosticsVersionsByPath?.get(normalizedPath);
|
|
478
772
|
logLatency({
|
|
479
773
|
type: "phase",
|
|
480
774
|
phase: "lsp_typescript_diagnostic_sequence",
|
|
481
775
|
filePath: normalizedPath,
|
|
482
|
-
durationMs
|
|
776
|
+
durationMs,
|
|
483
777
|
metadata: {
|
|
778
|
+
serverId: state.serverId,
|
|
779
|
+
outcome: "settled",
|
|
484
780
|
launchVariant: state.launchVariant ?? "unknown",
|
|
485
781
|
publicationIndex: state.diagnosticPublicationCounts.get(normalizedPath) ?? 0,
|
|
486
|
-
version:
|
|
782
|
+
version: trackedVersion ?? "pull-unversioned",
|
|
487
783
|
diagnosticCount: diagnostics.length,
|
|
488
784
|
diagnosticCodes,
|
|
489
785
|
elapsedSinceDidOpenMs,
|
|
490
786
|
settledReturn: true,
|
|
491
|
-
settleSource:
|
|
787
|
+
settleSource: pullSettleSource,
|
|
492
788
|
},
|
|
493
789
|
});
|
|
494
790
|
}
|
|
@@ -504,7 +800,93 @@ function recordSentContent(state, normalizedPath, version, content) {
|
|
|
504
800
|
state.documentContentHashes.set(normalizedPath, {
|
|
505
801
|
version,
|
|
506
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
|
+
}),
|
|
507
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
|
+
},
|
|
839
|
+
});
|
|
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
|
+
];
|
|
508
890
|
}
|
|
509
891
|
// Methods that can be registered dynamically and map to operationSupport keys
|
|
510
892
|
const DYNAMIC_OPERATION_METHOD_MAP = {
|
|
@@ -522,14 +904,23 @@ const DYNAMIC_OPERATION_METHOD_MAP = {
|
|
|
522
904
|
"textDocument/prepareCallHierarchy": "callHierarchy",
|
|
523
905
|
};
|
|
524
906
|
export function applyDynamicCapabilities(state) {
|
|
525
|
-
const
|
|
907
|
+
const registrations = [...state.dynamicRegistrations.values()];
|
|
908
|
+
const registeredMethods = new Set(registrations.map((r) => r.method));
|
|
526
909
|
const hasDynamicPull = registeredMethods.has("textDocument/diagnostic") ||
|
|
527
910
|
registeredMethods.has("workspace/diagnostic");
|
|
528
911
|
if (hasDynamicPull) {
|
|
529
912
|
state.workspaceDiagnosticsSupport = {
|
|
530
913
|
advertised: true,
|
|
531
914
|
mode: "pull",
|
|
532
|
-
|
|
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"),
|
|
533
924
|
diagnosticProviderKind: "dynamic",
|
|
534
925
|
};
|
|
535
926
|
}
|
|
@@ -549,15 +940,6 @@ export function applyDynamicCapabilities(state) {
|
|
|
549
940
|
}
|
|
550
941
|
}
|
|
551
942
|
}
|
|
552
|
-
/**
|
|
553
|
-
* Resolve a `workspace/configuration` request item's `section` (a dot-path,
|
|
554
|
-
* e.g. "scan.jobs") against the server's `initializationOptions` blob.
|
|
555
|
-
* - No section (undefined/empty) → the whole blob, per spec ("if a scope
|
|
556
|
-
* isn't asked for" the client returns the full settings for that scope).
|
|
557
|
-
* - An unresolvable path → `null`, never the whole blob — a server asking
|
|
558
|
-
* for a section it doesn't get must not silently receive unrelated config.
|
|
559
|
-
* Exported for the #983 regression test.
|
|
560
|
-
*/
|
|
561
943
|
export function resolveConfigurationSection(initialization, section) {
|
|
562
944
|
if (!initialization)
|
|
563
945
|
return section ? null : {};
|
|
@@ -565,9 +947,7 @@ export function resolveConfigurationSection(initialization, section) {
|
|
|
565
947
|
return initialization;
|
|
566
948
|
let cur = initialization;
|
|
567
949
|
for (const part of section.split(".")) {
|
|
568
|
-
if (typeof cur !== "object" ||
|
|
569
|
-
cur === null ||
|
|
570
|
-
!Object.prototype.hasOwnProperty.call(cur, part)) {
|
|
950
|
+
if (typeof cur !== "object" || cur === null || !Object.hasOwn(cur, part)) {
|
|
571
951
|
return null;
|
|
572
952
|
}
|
|
573
953
|
cur = cur[part];
|
|
@@ -579,6 +959,12 @@ export function resolveConfigurationSection(initialization, section) {
|
|
|
579
959
|
// a real language server. Not part of the public client API surface.
|
|
580
960
|
export function setupIncomingHandlers(state, initialization) {
|
|
581
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();
|
|
582
968
|
const filePath = uriToPath(params.uri);
|
|
583
969
|
const normalizedPath = normalizeMapKey(filePath);
|
|
584
970
|
// A server can flush a queued publish after didClose during teardown.
|
|
@@ -608,12 +994,38 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
608
994
|
state.diagnosticPublicationCounts.set(normalizedPath, publicationIndex);
|
|
609
995
|
}
|
|
610
996
|
const diagnosticCodes = isTypeScriptTelemetry
|
|
611
|
-
? [
|
|
997
|
+
? [
|
|
998
|
+
...new Set(newDiags
|
|
612
999
|
.map((diagnostic) => diagnostic.code)
|
|
613
1000
|
.filter((code) => code !== undefined)
|
|
614
|
-
.map(String))
|
|
1001
|
+
.map(String)),
|
|
1002
|
+
].slice(0, 8)
|
|
615
1003
|
: [];
|
|
616
|
-
|
|
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) => {
|
|
617
1029
|
if (state.serverId !== "typescript")
|
|
618
1030
|
return;
|
|
619
1031
|
const elapsedSinceDidOpenMs = Math.max(0, Date.now() -
|
|
@@ -622,16 +1034,18 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
622
1034
|
type: "phase",
|
|
623
1035
|
phase: "lsp_typescript_diagnostic_sequence",
|
|
624
1036
|
filePath: normalizedPath,
|
|
625
|
-
durationMs
|
|
1037
|
+
durationMs,
|
|
626
1038
|
metadata: {
|
|
1039
|
+
serverId: state.serverId,
|
|
1040
|
+
outcome: settledReturn ? "settled" : "published",
|
|
627
1041
|
launchVariant: state.launchVariant ?? "unknown",
|
|
628
1042
|
publicationIndex,
|
|
629
|
-
version: docVersion ??
|
|
1043
|
+
version: docVersion ?? "push-unversioned",
|
|
630
1044
|
diagnosticCount: newDiags.length,
|
|
631
1045
|
diagnosticCodes,
|
|
632
1046
|
elapsedSinceDidOpenMs,
|
|
633
1047
|
settledReturn,
|
|
634
|
-
|
|
1048
|
+
settleSource,
|
|
635
1049
|
},
|
|
636
1050
|
});
|
|
637
1051
|
};
|
|
@@ -679,10 +1093,12 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
679
1093
|
// Known, deliberately out-of-scope gaps: the pull-diagnostics path
|
|
680
1094
|
// (clientRequestPullDiagnostics/clientRequestWorkspaceDiagnostics) has no
|
|
681
1095
|
// version stamp to compare against in this codebase's current handling,
|
|
682
|
-
// so nothing analogous is applied there.
|
|
683
|
-
// single global counter
|
|
684
|
-
// fresh push
|
|
685
|
-
//
|
|
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.
|
|
686
1102
|
const isSupersededPush = () => {
|
|
687
1103
|
if (docVersion === undefined)
|
|
688
1104
|
return false;
|
|
@@ -698,12 +1114,14 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
698
1114
|
state.pushDiagnostics.set(normalizedPath, newDiags);
|
|
699
1115
|
state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
|
|
700
1116
|
recordDocVersion();
|
|
701
|
-
state
|
|
1117
|
+
bumpDiagnosticsVersion(state, normalizedPath);
|
|
702
1118
|
state.diagnosticEmitter.emit("diagnostics", normalizedPath);
|
|
703
|
-
|
|
1119
|
+
// Immediate settle — no debounce wait to measure.
|
|
1120
|
+
logSequence(true, "first-push", 0);
|
|
704
1121
|
return;
|
|
705
1122
|
}
|
|
706
|
-
|
|
1123
|
+
// A raw per-publication receipt, not itself a settle.
|
|
1124
|
+
logSequence(false, "publication", 0);
|
|
707
1125
|
const existingTimer = state.pendingDiagnostics.get(normalizedPath);
|
|
708
1126
|
if (existingTimer)
|
|
709
1127
|
clearTimeout(existingTimer);
|
|
@@ -714,9 +1132,10 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
714
1132
|
state.pushDiagnostics.set(normalizedPath, newDiags);
|
|
715
1133
|
state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
|
|
716
1134
|
recordDocVersion();
|
|
717
|
-
state
|
|
1135
|
+
bumpDiagnosticsVersion(state, normalizedPath);
|
|
718
1136
|
state.diagnosticEmitter.emit("diagnostics", normalizedPath);
|
|
719
|
-
|
|
1137
|
+
// The real debounce wait, timed from THIS publish's own receipt.
|
|
1138
|
+
logSequence(true, "quiet-window", Date.now() - publishReceivedAt);
|
|
720
1139
|
}, strategy.debounceMs);
|
|
721
1140
|
state.pendingDiagnostics.set(normalizedPath, timer);
|
|
722
1141
|
});
|
|
@@ -725,25 +1144,57 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
725
1144
|
]);
|
|
726
1145
|
state.connection.onRequest("client/registerCapability", async (params) => {
|
|
727
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;
|
|
728
1151
|
if (reg.id && reg.method) {
|
|
729
|
-
|
|
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
|
+
});
|
|
730
1166
|
}
|
|
731
1167
|
// executeCommand commands can arrive dynamically too — merge them
|
|
732
1168
|
// into the allowlist so dynamically-registered commands are runnable.
|
|
733
|
-
if (reg.method === "workspace/executeCommand" &&
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
if (typeof cmd === "string")
|
|
737
|
-
state.advertisedCommands.add(cmd);
|
|
738
|
-
}
|
|
1169
|
+
if (reg.method === "workspace/executeCommand" && commands) {
|
|
1170
|
+
for (const cmd of commands)
|
|
1171
|
+
state.advertisedCommands.add(cmd);
|
|
739
1172
|
}
|
|
740
1173
|
}
|
|
741
1174
|
applyDynamicCapabilities(state);
|
|
742
1175
|
});
|
|
743
1176
|
state.connection.onRequest("client/unregisterCapability", async (params) => {
|
|
1177
|
+
const retiredIdentifiers = new Set();
|
|
744
1178
|
for (const unreg of params?.unregisterations ?? []) {
|
|
745
|
-
if (unreg.id)
|
|
746
|
-
|
|
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);
|
|
747
1198
|
}
|
|
748
1199
|
}
|
|
749
1200
|
applyDynamicCapabilities(state);
|
|
@@ -792,6 +1243,128 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
792
1243
|
return items.map((item) => resolveConfigurationSection(initialization, item?.section));
|
|
793
1244
|
});
|
|
794
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
|
+
});
|
|
795
1368
|
}
|
|
796
1369
|
/**
|
|
797
1370
|
* #1127: record the FIRST moment this client observed its own death. Detection
|
|
@@ -849,27 +1422,196 @@ function setupConnectionLifecycle(state, recentStderr) {
|
|
|
849
1422
|
});
|
|
850
1423
|
}
|
|
851
1424
|
});
|
|
1425
|
+
// #1969: the exit CAUSE, in the degradation ledger.
|
|
1426
|
+
//
|
|
1427
|
+
// The `exit` log above is one latency.log line per death. That line is
|
|
1428
|
+
// necessary but not sufficient: an ast-grep child died 14 times in one day
|
|
1429
|
+
// with `code=1` and empty stderr, and the only thing visible downstream was
|
|
1430
|
+
// 19 `lsp_client_skipped_broken` cooldowns and 32 coverage gaps — fallout
|
|
1431
|
+
// with no cause attached. The ledger is what a session-end health read
|
|
1432
|
+
// looks at, so the cause belongs there too, counted per server.
|
|
1433
|
+
//
|
|
1434
|
+
// `close` rather than `exit`: `close` fires only after the child's stdio
|
|
1435
|
+
// streams have drained, so "stderr was empty" is a statement about the
|
|
1436
|
+
// server rather than a race against the pipe. `exit` can arrive first with
|
|
1437
|
+
// stderr still buffered.
|
|
1438
|
+
//
|
|
1439
|
+
// Gated on `shutdownRequested` exactly as the exit log is — an eviction or
|
|
1440
|
+
// an ordinary teardown is not a degradation and must not be recorded.
|
|
1441
|
+
// `incrementDegradationCount` counts every occurrence and retains the
|
|
1442
|
+
// latest reason per server, so a server dying in a loop shows as one entry
|
|
1443
|
+
// with an honest count rather than N entries.
|
|
1444
|
+
state.lspProcess.process.on("close", (code, signal) => {
|
|
1445
|
+
if (state.shutdownRequested)
|
|
1446
|
+
return;
|
|
1447
|
+
try {
|
|
1448
|
+
const stderrTail = recentStderr(20);
|
|
1449
|
+
incrementDegradationCount({
|
|
1450
|
+
kind: "lsp-server-unexpected-close",
|
|
1451
|
+
subject: state.serverId,
|
|
1452
|
+
reason: `closed unprompted: code=${code ?? "none"} signal=${signal ?? "none"} ` +
|
|
1453
|
+
`stderr=${stderrTail.length > 0 ? `present(${stderrTail.length}b)` : "empty"} root=${state.root}`,
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
catch {
|
|
1457
|
+
// Telemetry must never break the observed path.
|
|
1458
|
+
}
|
|
1459
|
+
});
|
|
852
1460
|
}
|
|
1461
|
+
/** Margin between each source's own request timeout and the race's backstop
|
|
1462
|
+
* deadline, so the natural "all sources settled" path wins over the backstop
|
|
1463
|
+
* and the race never reports `unavailable` for sources that did answer. */
|
|
1464
|
+
const PULL_RACE_BACKSTOP_MARGIN_MS = 25;
|
|
1465
|
+
/**
|
|
1466
|
+
* #1667: pull EVERY registered diagnostic source for a file, in parallel, and
|
|
1467
|
+
* answer as soon as the first source returns findings for that file.
|
|
1468
|
+
*
|
|
1469
|
+
* Roslyn, vtsls and some Java setups register `textDocument/diagnostic` once
|
|
1470
|
+
* per source (syntax, semantic, analyzers), each named by
|
|
1471
|
+
* `registerOptions.identifier`. One bare request asks only the server's default
|
|
1472
|
+
* source, so whole categories of findings were never requested. The fan-out
|
|
1473
|
+
* asks all of them plus the bare request.
|
|
1474
|
+
*
|
|
1475
|
+
* Timing is deliberate and load-bearing:
|
|
1476
|
+
* - PARALLEL, never one source after another. Sequencing would add every
|
|
1477
|
+
* slow source's latency to every touch.
|
|
1478
|
+
* - The race resolves on the FIRST source with findings for the file, with no
|
|
1479
|
+
* settle or debounce window after the match. A post-match wait is the exact
|
|
1480
|
+
* latency shape #1112/#1407 already cost this codebase once.
|
|
1481
|
+
* - Losing sources are NOT cancelled. Each source writes its own findings into
|
|
1482
|
+
* the pull cache before it resolves, so a late answer merges in the
|
|
1483
|
+
* background and the NEXT read of the file sees it. Late answers are not
|
|
1484
|
+
* dropped, they are just not waited for.
|
|
1485
|
+
*
|
|
1486
|
+
* The race runs inside the existing `raceToCompletion` primitive rather than a
|
|
1487
|
+
* second first-wins helper.
|
|
1488
|
+
*/
|
|
853
1489
|
async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQUEST_TIMEOUT_MS) {
|
|
854
1490
|
if (!isClientAlive(state))
|
|
855
1491
|
return { status: "unavailable" };
|
|
856
|
-
const
|
|
1492
|
+
const sources = pullDiagnosticSources(state);
|
|
1493
|
+
if (sources.length === 1) {
|
|
1494
|
+
// The overwhelmingly common case: no named sources registered. Skip the
|
|
1495
|
+
// race entirely so a single-source server pays nothing for this feature.
|
|
1496
|
+
return aggregatePullOutcomes(state, filePath, [
|
|
1497
|
+
await pullDiagnosticSource(state, filePath, budgetMs, undefined),
|
|
1498
|
+
]);
|
|
1499
|
+
}
|
|
1500
|
+
const attempts = sources.map((identifier) => pullDiagnosticSource(state, filePath, budgetMs, identifier));
|
|
1501
|
+
const settled = await raceToCompletion(attempts, (results) => results.some((r) => r.status === "found" && r.primaryCount > 0), {
|
|
1502
|
+
// No graceMs: finalize the instant a source answers for this file.
|
|
1503
|
+
// The per-request `withTimeout` inside each attempt is the real bound;
|
|
1504
|
+
// this deadline is only a backstop, so it sits just past it.
|
|
1505
|
+
timeoutMs: Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)) +
|
|
1506
|
+
PULL_RACE_BACKSTOP_MARGIN_MS,
|
|
1507
|
+
});
|
|
1508
|
+
return aggregatePullOutcomes(state, filePath, settled);
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Collapse the sources that answered into one outcome for the caller.
|
|
1512
|
+
* `found` beats `clean` beats `unavailable`: a source with findings is an
|
|
1513
|
+
* answer, an authoritative empty report is an answer, and only "nobody
|
|
1514
|
+
* answered" is unavailable (#240 — an empty result must distinguish clean
|
|
1515
|
+
* from errored).
|
|
1516
|
+
*/
|
|
1517
|
+
function aggregatePullOutcomes(state, filePath, outcomes) {
|
|
1518
|
+
const found = outcomes.filter((o) => o.status === "found");
|
|
1519
|
+
if (found.length > 0) {
|
|
1520
|
+
const normalizedPath = normalizeMapKey(filePath);
|
|
1521
|
+
// The union for the requested file, which is what a caller reading the
|
|
1522
|
+
// cache will see. A source that only reported `relatedDocuments` findings
|
|
1523
|
+
// contributes nothing there, so fall back to its own count.
|
|
1524
|
+
const unionCount = state.documentPullDiagnostics.get(normalizedPath)?.length ?? 0;
|
|
1525
|
+
return {
|
|
1526
|
+
status: "found",
|
|
1527
|
+
count: Math.max(unionCount, ...found.map((o) => o.count)),
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
// #240, applied across sources: `clean` is an authoritative "this file has no
|
|
1531
|
+
// findings", and one source saying so proves nothing about a source that
|
|
1532
|
+
// FAILED to answer. A fan-out where any source errored is therefore
|
|
1533
|
+
// unavailable, not clean - the caller falls through to the push-wait/timeout
|
|
1534
|
+
// backstop instead of recording a confirmed-clean touch it cannot support.
|
|
1535
|
+
// (`raceToCompletion` drops still-pending promises from its result array, so
|
|
1536
|
+
// "every source settled, none errored" is the only shape that reads clean.)
|
|
1537
|
+
if (outcomes.some((o) => o.status === "unavailable")) {
|
|
1538
|
+
return { status: "unavailable" };
|
|
1539
|
+
}
|
|
1540
|
+
return outcomes.some((o) => o.status === "clean")
|
|
1541
|
+
? { status: "clean" }
|
|
1542
|
+
: { status: "unavailable" };
|
|
1543
|
+
}
|
|
1544
|
+
async function pullDiagnosticSource(state, filePath, budgetMs, identifier) {
|
|
1545
|
+
if (!isClientAlive(state))
|
|
1546
|
+
return { status: "unavailable" };
|
|
857
1547
|
const normalizedPath = normalizeMapKey(filePath);
|
|
1548
|
+
// #1773: a budget at or below the usable floor cannot complete a real
|
|
1549
|
+
// round trip. Skip the dispatch entirely — no `safeSendRequest` call, no
|
|
1550
|
+
// generation/sequence claim — and say so with a distinct record, so
|
|
1551
|
+
// `lsp_pull_diagnostic_timeout` only ever means a pull was genuinely
|
|
1552
|
+
// attempted. The caller sees the same `unavailable` outcome a timeout
|
|
1553
|
+
// would have produced (#240: unavailable, never a false clean), so
|
|
1554
|
+
// nothing downstream of this call changes shape.
|
|
1555
|
+
if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
|
|
1556
|
+
emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${pullSourceKey(normalizedPath, identifier)}`, {
|
|
1557
|
+
type: "phase",
|
|
1558
|
+
filePath: normalizedPath,
|
|
1559
|
+
durationMs: 0,
|
|
1560
|
+
metadata: {
|
|
1561
|
+
identifier: identifier ?? "bare",
|
|
1562
|
+
remainingBudgetMs: budgetMs,
|
|
1563
|
+
server: state.serverId,
|
|
1564
|
+
},
|
|
1565
|
+
}, {
|
|
1566
|
+
ledgerKind: "lsp-pull-skipped-budget-exhausted",
|
|
1567
|
+
reason: `pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
|
|
1568
|
+
});
|
|
1569
|
+
return { status: "unavailable" };
|
|
1570
|
+
}
|
|
1571
|
+
const uri = pathToFileURL(filePath).href;
|
|
858
1572
|
// #1104: echo the last resultId we hold for this document so a server that
|
|
859
1573
|
// hasn't changed its view can answer `kind: "unchanged"` instead of
|
|
860
1574
|
// recomputing — see the `kind === "unchanged"` branch below for how that's
|
|
861
1575
|
// honored (inherit, never treat an omitted `items` as clean).
|
|
862
|
-
|
|
1576
|
+
// #1667: read THIS source's id, never another source's. Result ids are
|
|
1577
|
+
// issued per source, so echoing a syntax id on a semantic request asks the
|
|
1578
|
+
// server to compare against a basis it never handed out.
|
|
1579
|
+
const sourceKey = pullSourceKey(normalizedPath, identifier);
|
|
1580
|
+
if (state.abandonedPullRequests?.has(sourceKey)) {
|
|
1581
|
+
return { status: "unavailable" };
|
|
1582
|
+
}
|
|
1583
|
+
const previousResultId = state.pullResultIds.get(sourceKey);
|
|
1584
|
+
// #1667: the generation this pull was computed against. A resync landing
|
|
1585
|
+
// while the request is in flight bumps it, and every write below is dropped
|
|
1586
|
+
// rather than resurrecting state the resync cleared.
|
|
1587
|
+
const generation = pullGenerationFor(state, normalizedPath);
|
|
1588
|
+
// #1667: claim the newest-request slot for THIS source. Two fan-outs can
|
|
1589
|
+
// overlap for the same file with no resync between them (`ensureWarmForSweep`
|
|
1590
|
+
// touches, then the real touch follows ~60ms later), so the generation check
|
|
1591
|
+
// alone would let a slow answer from the earlier round land on top of a newer
|
|
1592
|
+
// one. Re-checked at write time below.
|
|
1593
|
+
const requestSequence = claimPullRequestSequence(state, sourceKey);
|
|
1594
|
+
// #1889: give the request a cancellation token. `withTimeout` only abandons
|
|
1595
|
+
// its await; aborting in the timeout branch below also sends `$/cancelRequest`
|
|
1596
|
+
// so repeated touches cannot leave an aging backlog inside the server.
|
|
1597
|
+
const requestStartedAt = Date.now();
|
|
1598
|
+
const effectiveTimeoutMs = Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs));
|
|
1599
|
+
const pullAbort = new AbortController();
|
|
1600
|
+
const pullSettlement = { cancelled: false };
|
|
1601
|
+
const requestPromise = safeSendRequest(state.connection, "textDocument/diagnostic", {
|
|
1602
|
+
textDocument: { uri },
|
|
1603
|
+
// #1667: name the source this request is for, so the server answers
|
|
1604
|
+
// from that source instead of its default one.
|
|
1605
|
+
...(identifier !== undefined && { identifier }),
|
|
1606
|
+
...(previousResultId !== undefined && { previousResultId }),
|
|
1607
|
+
}, pullAbort.signal, pullSettlement);
|
|
863
1608
|
try {
|
|
864
1609
|
// withTimeout is the backstop against a hung pull-mode server: without it
|
|
865
1610
|
// this await never settles unless the stream is destroyed. Bounded by the
|
|
866
1611
|
// smaller of the absolute ceiling and the caller's remaining wait budget.
|
|
867
1612
|
// On timeout the caught error yields `unavailable` below (never a false
|
|
868
1613
|
// `clean`), so it falls through to the push-wait/timeout backstop.
|
|
869
|
-
const report = await withTimeout(
|
|
870
|
-
textDocument: { uri },
|
|
871
|
-
...(previousResultId !== undefined && { previousResultId }),
|
|
872
|
-
}), Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)));
|
|
1614
|
+
const report = await withTimeout(requestPromise, effectiveTimeoutMs);
|
|
873
1615
|
if (!report) {
|
|
874
1616
|
recordPullFailure(state, "textDocument/diagnostic", new Error("empty response"));
|
|
875
1617
|
return { status: "unavailable" };
|
|
@@ -882,13 +1624,28 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
|
|
|
882
1624
|
// response describes whatever the server had when it answered, which for
|
|
883
1625
|
// a pi-lens-opened document is exactly that last-sent payload.
|
|
884
1626
|
const sentHash = state.documentContentHashes.get(normalizedPath)?.hash;
|
|
885
|
-
|
|
1627
|
+
// #1667: two ways this answer can already be obsolete, and neither may
|
|
1628
|
+
// write. Report the round trip as unavailable - a superseded answer is not
|
|
1629
|
+
// evidence the file is clean (#240).
|
|
1630
|
+
// - A resync landed while the request was in flight, so these findings
|
|
1631
|
+
// describe content that no longer exists.
|
|
1632
|
+
// - A LATER request for this same source has already been issued (an
|
|
1633
|
+
// overlapping fan-out), so this answer is the stale loser and would
|
|
1634
|
+
// clobber the newer result.
|
|
1635
|
+
if (pullGenerationFor(state, normalizedPath) !== generation ||
|
|
1636
|
+
!isNewestPullRequest(state, sourceKey, requestSequence)) {
|
|
1637
|
+
return { status: "unavailable" };
|
|
1638
|
+
}
|
|
1639
|
+
let primaryCount;
|
|
886
1640
|
if (report.kind === "unchanged") {
|
|
887
1641
|
// #1104: same resultId basis as last time — an omitted `items` here
|
|
888
1642
|
// means "no change", NOT "clean". Overwriting with `[]` would be the
|
|
889
1643
|
// exact false-clean shape #570/#571 already fixed for the touch path;
|
|
890
1644
|
// keep the previously stored diagnostics and binding as-is.
|
|
891
|
-
|
|
1645
|
+
// #1667: "the previously stored diagnostics" means THIS SOURCE's, so
|
|
1646
|
+
// one source reporting unchanged neither inherits nor disturbs another
|
|
1647
|
+
// source's findings for the same file.
|
|
1648
|
+
primaryCount = pullSourceDiagnostics(state, normalizedPath, identifier).length;
|
|
892
1649
|
// Still a fresh confirmation as of `now` even though the content is
|
|
893
1650
|
// unchanged — bump the timestamp so `getAllDiagnostics()` doesn't read
|
|
894
1651
|
// this entry as aging purely because the server had nothing new to say.
|
|
@@ -896,30 +1653,43 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
|
|
|
896
1653
|
}
|
|
897
1654
|
else {
|
|
898
1655
|
const primaryItems = normalizeLspDiagnostics(report.items ?? []);
|
|
899
|
-
|
|
1656
|
+
// #1667: replace only this source's slice; `documentPullDiagnostics`
|
|
1657
|
+
// becomes the deduped union of every source that has reported.
|
|
1658
|
+
storePullSourceDiagnostics(state, normalizedPath, identifier, primaryItems);
|
|
900
1659
|
state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
|
|
901
|
-
state
|
|
1660
|
+
bumpDiagnosticsVersion(state, normalizedPath);
|
|
902
1661
|
state.diagnosticBindings.set(normalizedPath, { contentHash: sentHash });
|
|
903
|
-
|
|
1662
|
+
primaryCount = primaryItems.length;
|
|
904
1663
|
}
|
|
1664
|
+
let totalCount = primaryCount;
|
|
905
1665
|
if (report.resultId !== undefined) {
|
|
906
|
-
state.pullResultIds.set(
|
|
1666
|
+
state.pullResultIds.set(sourceKey, report.resultId);
|
|
907
1667
|
}
|
|
908
1668
|
else {
|
|
909
|
-
state.pullResultIds.delete(
|
|
1669
|
+
state.pullResultIds.delete(sourceKey);
|
|
910
1670
|
}
|
|
1671
|
+
// #1531 note (pre-existing, unchanged): a related document's diagnostics are
|
|
1672
|
+
// STORED below but earn no version bump and therefore no per-path stamp —
|
|
1673
|
+
// exactly as before, since this loop never bumped the global counter either.
|
|
1674
|
+
// A wait on a path that only ever hears about itself through some other
|
|
1675
|
+
// document's `relatedDocuments` sees no freshness evidence and rides its
|
|
1676
|
+
// timeout. Direction is under-detection, and correcting it means deciding
|
|
1677
|
+
// what a related-document publication is evidence OF (its binding is
|
|
1678
|
+
// honestly "unknown" per #1104), so it stays out of scope here.
|
|
911
1679
|
if (report.relatedDocuments) {
|
|
912
1680
|
for (const [relatedUri, related] of Object.entries(report.relatedDocuments)) {
|
|
913
1681
|
const relatedPath = uriToPath(relatedUri);
|
|
914
1682
|
const relatedNormalized = normalizeMapKey(relatedPath);
|
|
1683
|
+
// #1667: a related document's report belongs to the SAME source as
|
|
1684
|
+
// the request that carried it, so it stores and inherits under that
|
|
1685
|
+
// source's key too.
|
|
915
1686
|
if (related?.kind === "unchanged") {
|
|
916
|
-
totalCount +=
|
|
917
|
-
state.documentPullDiagnostics.get(relatedNormalized)?.length ?? 0;
|
|
1687
|
+
totalCount += pullSourceDiagnostics(state, relatedNormalized, identifier).length;
|
|
918
1688
|
state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
|
|
919
1689
|
}
|
|
920
1690
|
else {
|
|
921
1691
|
const relatedItems = normalizeLspDiagnostics(related?.items ?? []);
|
|
922
|
-
state
|
|
1692
|
+
storePullSourceDiagnostics(state, relatedNormalized, identifier, relatedItems);
|
|
923
1693
|
state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
|
|
924
1694
|
// #1104: a related document's diagnostics were NOT computed against
|
|
925
1695
|
// content we independently sent/fingerprinted (we never requested
|
|
@@ -929,20 +1699,186 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
|
|
|
929
1699
|
totalCount += relatedItems.length;
|
|
930
1700
|
}
|
|
931
1701
|
if (related?.resultId !== undefined) {
|
|
932
|
-
state.pullResultIds.set(relatedNormalized, related.resultId);
|
|
1702
|
+
state.pullResultIds.set(pullSourceKey(relatedNormalized, identifier), related.resultId);
|
|
933
1703
|
}
|
|
934
1704
|
}
|
|
935
1705
|
}
|
|
936
1706
|
state.diagnosticEmitter.emit("diagnostics", normalizedPath);
|
|
937
1707
|
return totalCount > 0
|
|
938
|
-
? { status: "found", count: totalCount }
|
|
1708
|
+
? { status: "found", count: totalCount, primaryCount }
|
|
939
1709
|
: { status: "clean" };
|
|
940
1710
|
}
|
|
941
1711
|
catch (err) {
|
|
1712
|
+
if (isPullTimeoutError(err, effectiveTimeoutMs)) {
|
|
1713
|
+
trackAbandonedPullRequest(state, sourceKey, requestPromise);
|
|
1714
|
+
pullAbort.abort();
|
|
1715
|
+
recordPullTimeoutTelemetry({
|
|
1716
|
+
scope: normalizedPath,
|
|
1717
|
+
identifier,
|
|
1718
|
+
effectiveBudgetMs: effectiveTimeoutMs,
|
|
1719
|
+
hadPreviousResultId: previousResultId !== undefined,
|
|
1720
|
+
elapsedMs: Date.now() - requestStartedAt,
|
|
1721
|
+
server: state.serverId,
|
|
1722
|
+
});
|
|
1723
|
+
armLateAnswerTelemetry({
|
|
1724
|
+
requestPromise,
|
|
1725
|
+
scope: normalizedPath,
|
|
1726
|
+
identifier,
|
|
1727
|
+
subject: sourceKey,
|
|
1728
|
+
requestStartedAt,
|
|
1729
|
+
server: state.serverId,
|
|
1730
|
+
settlement: pullSettlement,
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
942
1733
|
recordPullFailure(state, "textDocument/diagnostic", err);
|
|
943
1734
|
return { status: "unavailable" };
|
|
944
1735
|
}
|
|
945
1736
|
}
|
|
1737
|
+
function trackAbandonedPullRequest(state, sourceKey, requestPromise) {
|
|
1738
|
+
if (!state.abandonedPullRequests)
|
|
1739
|
+
state.abandonedPullRequests = new Map();
|
|
1740
|
+
state.abandonedPullRequests.set(sourceKey, requestPromise);
|
|
1741
|
+
const release = () => {
|
|
1742
|
+
if (state.abandonedPullRequests?.get(sourceKey) === requestPromise) {
|
|
1743
|
+
state.abandonedPullRequests.delete(sourceKey);
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
requestPromise.then(release, release);
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* #1713: `withTimeout` rejects with this EXACT message (`clients/deadline-utils.ts`)
|
|
1750
|
+
* only when its own timer wins the race — every other rejection is the request
|
|
1751
|
+
* itself failing (bad params, dead connection, thrown by the server). Only the
|
|
1752
|
+
* timer-won case is a "the answer might still be coming" situation worth a
|
|
1753
|
+
* late-answer watch; a request that already failed fast has nothing further to
|
|
1754
|
+
* observe.
|
|
1755
|
+
*/
|
|
1756
|
+
function isPullTimeoutError(err, timeoutMs) {
|
|
1757
|
+
return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* #1713: every pull timeout emits exactly one bounded latency.log record — the
|
|
1761
|
+
* observability gap the issue names (`withTimeout`'s throw previously skipped
|
|
1762
|
+
* ALL telemetry for this site). Never throws: telemetry must not perturb the
|
|
1763
|
+
* timeout path, which has already decided to return `unavailable` regardless.
|
|
1764
|
+
*
|
|
1765
|
+
* #1743: routed through `emitBounded`. This phase takes NO rising-edge gate
|
|
1766
|
+
* and NO per-turn cap, which is the pre-existing behavior kept exactly: a
|
|
1767
|
+
* pull timeout costs at least the full budget (seconds), so the call cadence
|
|
1768
|
+
* already bounds the volume, unlike `navRequest` below. The helper still
|
|
1769
|
+
* supplies the identity discipline and the registry membership the sweep
|
|
1770
|
+
* checks.
|
|
1771
|
+
*/
|
|
1772
|
+
function recordPullTimeoutTelemetry(args) {
|
|
1773
|
+
// #1771: this record is written on a genuine failure path (a dispatched
|
|
1774
|
+
// pull that never answered), so per the bounded-telemetry rule
|
|
1775
|
+
// (`clients/bounded-telemetry.ts`) it needs a `ledgerKind`, not just the
|
|
1776
|
+
// detailed latency.log record. Before this it counted nothing in the
|
|
1777
|
+
// degradation ledger — every occurrence wrote a detail line, but nothing
|
|
1778
|
+
// tallied. No `risingEdgePer`: timeouts repeat and every one should count,
|
|
1779
|
+
// matching the pre-existing "no rising-edge gate" behavior for the
|
|
1780
|
+
// detailed record itself.
|
|
1781
|
+
emitBounded("lsp_pull_diagnostic_timeout", `${args.server}::${args.scope}::${args.identifier ?? "bare"}`, {
|
|
1782
|
+
type: "phase",
|
|
1783
|
+
filePath: args.scope,
|
|
1784
|
+
durationMs: args.elapsedMs,
|
|
1785
|
+
metadata: {
|
|
1786
|
+
identifier: args.identifier ?? "bare",
|
|
1787
|
+
effectiveBudgetMs: args.effectiveBudgetMs,
|
|
1788
|
+
hadPreviousResultId: args.hadPreviousResultId,
|
|
1789
|
+
server: args.server,
|
|
1790
|
+
},
|
|
1791
|
+
}, {
|
|
1792
|
+
ledgerKind: "lsp-pull-diagnostic-timeout",
|
|
1793
|
+
reason: `pull timeout on ${args.server} after ${args.elapsedMs}ms (budget ${args.effectiveBudgetMs}ms)`,
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* #1713: a telemetry-only continuation on the ALREADY-abandoned request
|
|
1798
|
+
* promise. `withTimeout` only raced it against a timer — the request is still
|
|
1799
|
+
* live server-side — so if it eventually resolves, this is the only place that
|
|
1800
|
+
* ever finds out. Purely observational: it runs after the caller has already
|
|
1801
|
+
* moved on with `unavailable`, and it never writes into `state`.
|
|
1802
|
+
*
|
|
1803
|
+
* Bounded via the degradation-ledger convention (`incrementDegradationCount`,
|
|
1804
|
+
* chosen over a hand-rolled per-identity rising-edge Set): the ledger already
|
|
1805
|
+
* caps entries per kind and re-arms at `session_start`
|
|
1806
|
+
* (`resetDegradationLedger`, wired into `handleSessionStart`), so reusing it
|
|
1807
|
+
* avoids a second latch this fix would otherwise have to register and reset
|
|
1808
|
+
* itself. `subject` is the same `pullSourceKey` (path + identifier) the pull
|
|
1809
|
+
* path already uses, so the discriminating identity survives aggregation.
|
|
1810
|
+
*
|
|
1811
|
+
* Attaching `.then` here creates no timer, socket, or other handle — it only
|
|
1812
|
+
* observes a promise the request already created — so it cannot keep the
|
|
1813
|
+
* process alive a moment longer than that pending request already does.
|
|
1814
|
+
*/
|
|
1815
|
+
function armLateAnswerTelemetry(args) {
|
|
1816
|
+
args.requestPromise.then(() => {
|
|
1817
|
+
if (args.settlement.cancelled)
|
|
1818
|
+
return;
|
|
1819
|
+
try {
|
|
1820
|
+
const elapsedMs = Date.now() - args.requestStartedAt;
|
|
1821
|
+
// #1743: `emitBounded` counts every late answer in the ledger
|
|
1822
|
+
// under the same `pullSourceKey` subject as before, then writes
|
|
1823
|
+
// the record. No rising-edge gate, matching #1713's original
|
|
1824
|
+
// behavior: a late answer arrives at most once per abandoned
|
|
1825
|
+
// request, so the record count cannot exceed the timeout count.
|
|
1826
|
+
emitBounded("lsp_pull_late_answer_discarded", args.subject, {
|
|
1827
|
+
type: "phase",
|
|
1828
|
+
filePath: args.scope,
|
|
1829
|
+
durationMs: elapsedMs,
|
|
1830
|
+
metadata: { identifier: args.identifier ?? "bare" },
|
|
1831
|
+
}, {
|
|
1832
|
+
ledgerKind: "lsp-pull-late-answer",
|
|
1833
|
+
reason: `late pull answer discarded after ${elapsedMs}ms`,
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
catch {
|
|
1837
|
+
// Telemetry must never break the observed path.
|
|
1838
|
+
}
|
|
1839
|
+
}, (err) => {
|
|
1840
|
+
// #1774: the abandoned request eventually REJECTED rather than
|
|
1841
|
+
// answering. Behavior is unchanged — the rejection is still
|
|
1842
|
+
// swallowed here, exactly as before — but "timeout then silence"
|
|
1843
|
+
// and "timeout then server rejection (e.g. ContentModified)" were
|
|
1844
|
+
// previously indistinguishable: `recordPullFailure` at request
|
|
1845
|
+
// time reports the FIRST failure only, and this rejection happens
|
|
1846
|
+
// strictly after that request already timed out, so it needs its
|
|
1847
|
+
// own record. Bounded via the degradation ledger, same shape as
|
|
1848
|
+
// the late-answer branch above, so this rejection cannot outpace
|
|
1849
|
+
// the timeout count that gates it.
|
|
1850
|
+
try {
|
|
1851
|
+
const elapsedMs = Date.now() - args.requestStartedAt;
|
|
1852
|
+
const candidate = err;
|
|
1853
|
+
const code = typeof candidate?.code === "number" ||
|
|
1854
|
+
typeof candidate?.code === "string"
|
|
1855
|
+
? candidate.code
|
|
1856
|
+
: undefined;
|
|
1857
|
+
emitBounded("lsp_pull_late_rejection",
|
|
1858
|
+
// #1774 review: prefix the server, same reason the timeout kind
|
|
1859
|
+
// does (#1771). The workspace call site's `subject` is the bare
|
|
1860
|
+
// `WORKSPACE_PULL_SCOPE` constant — without the server prefix,
|
|
1861
|
+
// two servers' abandoned-workspace-pull rejections would collapse
|
|
1862
|
+
// into one ledger subject and hide which server is storming.
|
|
1863
|
+
`${args.server}::${args.subject}`, {
|
|
1864
|
+
type: "phase",
|
|
1865
|
+
filePath: args.scope,
|
|
1866
|
+
durationMs: elapsedMs,
|
|
1867
|
+
metadata: {
|
|
1868
|
+
identifier: args.identifier ?? "bare",
|
|
1869
|
+
server: args.server,
|
|
1870
|
+
...(code !== undefined && { code }),
|
|
1871
|
+
},
|
|
1872
|
+
}, {
|
|
1873
|
+
ledgerKind: "lsp-pull-late-rejection",
|
|
1874
|
+
reason: `late pull rejection ${elapsedMs}ms after timeout${code !== undefined ? ` (code ${code})` : ""}`,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
catch {
|
|
1878
|
+
// Telemetry must never break the observed path.
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
946
1882
|
const PULL_FAILURE_HISTORY_LIMIT = 10;
|
|
947
1883
|
function recordPullFailure(state, method, error) {
|
|
948
1884
|
const candidate = error;
|
|
@@ -958,9 +1894,7 @@ function recordPullFailure(state, method, error) {
|
|
|
958
1894
|
...(typeof candidate.code === "number" || typeof candidate.code === "string"
|
|
959
1895
|
? { code: candidate.code }
|
|
960
1896
|
: {}),
|
|
961
|
-
message: typeof candidate.message === "string"
|
|
962
|
-
? candidate.message
|
|
963
|
-
: String(error),
|
|
1897
|
+
message: typeof candidate.message === "string" ? candidate.message : String(error),
|
|
964
1898
|
});
|
|
965
1899
|
if (state.pullFailureHistory.length > PULL_FAILURE_HISTORY_LIMIT) {
|
|
966
1900
|
state.pullFailureHistory.splice(0, state.pullFailureHistory.length - PULL_FAILURE_HISTORY_LIMIT);
|
|
@@ -978,15 +1912,58 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
|
|
|
978
1912
|
return undefined;
|
|
979
1913
|
if (!state.workspaceDiagnosticsSupport.workspaceDiagnostics)
|
|
980
1914
|
return undefined;
|
|
1915
|
+
if (state.abandonedPullRequests?.has(WORKSPACE_PULL_SCOPE))
|
|
1916
|
+
return undefined;
|
|
1917
|
+
// #1104: echo every resultId we hold from a PRIOR pull so the server can
|
|
1918
|
+
// answer `kind: "unchanged"` for files it hasn't recomputed, instead of
|
|
1919
|
+
// resending (and us re-hashing) every file on every sweep.
|
|
1920
|
+
const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
|
|
1921
|
+
// #1773: same floor as `pullDiagnosticSource` — an exhausted budget here is
|
|
1922
|
+
// the SAME clamp-then-dispatch defect via a second entry point. Observed
|
|
1923
|
+
// live in the 2026-08-20 plegma dogfood session via
|
|
1924
|
+
// `lsp_workspace_diagnostics_start` (not just the `lens_diagnostics_full`
|
|
1925
|
+
// fan-out `pullDiagnosticSource` covers), so this call site needs its own
|
|
1926
|
+
// skip, not just a shared helper the caller happens to hit.
|
|
1927
|
+
if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
|
|
1928
|
+
emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${WORKSPACE_PULL_SCOPE}`, {
|
|
1929
|
+
type: "phase",
|
|
1930
|
+
filePath: WORKSPACE_PULL_SCOPE,
|
|
1931
|
+
durationMs: 0,
|
|
1932
|
+
metadata: {
|
|
1933
|
+
identifier: "bare",
|
|
1934
|
+
remainingBudgetMs: budgetMs,
|
|
1935
|
+
server: state.serverId,
|
|
1936
|
+
},
|
|
1937
|
+
}, {
|
|
1938
|
+
ledgerKind: "lsp-pull-skipped-budget-exhausted",
|
|
1939
|
+
reason: `workspace pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
|
|
1940
|
+
});
|
|
1941
|
+
return undefined;
|
|
1942
|
+
}
|
|
1943
|
+
// #1713: declared OUTSIDE the try so the catch below can still reach them —
|
|
1944
|
+
// same shape as `pullDiagnosticSource` above. Captured outside `withTimeout`
|
|
1945
|
+
// so a timeout still holds a live handle on the request for the late-answer
|
|
1946
|
+
// watch.
|
|
1947
|
+
const workspacePullStartedAt = Date.now();
|
|
1948
|
+
const workspacePullTimeoutMs = Math.max(1, budgetMs);
|
|
1949
|
+
const workspacePullAbort = new AbortController();
|
|
1950
|
+
const workspacePullSettlement = { cancelled: false };
|
|
1951
|
+
const workspaceRequestPromise = safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }, workspacePullAbort.signal, workspacePullSettlement);
|
|
981
1952
|
try {
|
|
982
|
-
|
|
983
|
-
// answer `kind: "unchanged"` for files it hasn't recomputed, instead of
|
|
984
|
-
// resending (and us re-hashing) every file on every sweep.
|
|
985
|
-
const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
|
|
986
|
-
const report = await withTimeout(safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }), Math.max(1, budgetMs));
|
|
1953
|
+
const report = await withTimeout(workspaceRequestPromise, workspacePullTimeoutMs);
|
|
987
1954
|
if (!report || !Array.isArray(report.items))
|
|
988
1955
|
return undefined;
|
|
989
1956
|
const out = [];
|
|
1957
|
+
// #1669 review N1: ONE deadline, shared across every "unchanged, no
|
|
1958
|
+
// cached basis" fallback pull this call makes — not a fresh `budgetMs`
|
|
1959
|
+
// grant handed to EACH one. A refresh clears `workspacePullResultCache`,
|
|
1960
|
+
// so the very next sweep after a refresh can route every item down this
|
|
1961
|
+
// path; granting each one the full budget serially turned a bounded call
|
|
1962
|
+
// unbounded (measured: 6 files x 300ms budget = 1844ms; worst case is
|
|
1963
|
+
// the normal case — 2000 files x 30s). Mirrors the shared-budget shape
|
|
1964
|
+
// `runWorkspaceDiagnosticsSwept`'s `perFileMs` bounds each file to,
|
|
1965
|
+
// scoped to fallback pulls made from within THIS one call.
|
|
1966
|
+
const fallbackPullDeadline = Date.now() + Math.max(1, budgetMs);
|
|
990
1967
|
for (const item of report.items) {
|
|
991
1968
|
if (!item?.uri)
|
|
992
1969
|
continue;
|
|
@@ -998,8 +1975,53 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
|
|
|
998
1975
|
// `items`, so without this a file the server confirmed unchanged
|
|
999
1976
|
// would silently drop out of the sweep result entirely.
|
|
1000
1977
|
const prior = state.workspacePullResultCache.get(normalizedPath);
|
|
1001
|
-
if (!prior)
|
|
1002
|
-
|
|
1978
|
+
if (!prior) {
|
|
1979
|
+
// #1669 review F4: no earlier basis to inherit (e.g. right after a
|
|
1980
|
+
// `workspace/diagnostic/refresh` cleared `workspacePullResultCache`)
|
|
1981
|
+
// — the server is answering "unchanged" against a resultId basis we
|
|
1982
|
+
// no longer hold, so there is nothing honest to report for this
|
|
1983
|
+
// file from THIS response. `continue`-ing here would make the file
|
|
1984
|
+
// silently absent from `out`, which the caller reads as "clean" —
|
|
1985
|
+
// a false clean for a file the server just told us it hasn't even
|
|
1986
|
+
// looked at freshly. Treat it as needs-full-pull: request the
|
|
1987
|
+
// per-file diagnostic directly instead.
|
|
1988
|
+
const remainingMs = fallbackPullDeadline - Date.now();
|
|
1989
|
+
if (remainingMs <= 0) {
|
|
1990
|
+
// #1669 review N1: the shared fallback-pull deadline is already
|
|
1991
|
+
// exhausted by earlier files in this same loop — bail WITHOUT
|
|
1992
|
+
// attempting a request (never fabricate clean; this file was
|
|
1993
|
+
// never actually asked about) and surface it as unavailable via
|
|
1994
|
+
// the same degradation ledger path as a real unavailable pull.
|
|
1995
|
+
incrementDegradationCount({
|
|
1996
|
+
kind: "lsp-pull-unconfirmed",
|
|
1997
|
+
subject: state.serverId,
|
|
1998
|
+
reason: "unchanged report with no workspacePullResultCache basis; the shared fallback-pull deadline was already exhausted by earlier files this sweep",
|
|
1999
|
+
});
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
const outcome = await clientRequestPullDiagnostics(state, filePath, remainingMs);
|
|
2003
|
+
if (outcome.status === "found") {
|
|
2004
|
+
out.push({
|
|
2005
|
+
filePath,
|
|
2006
|
+
diagnostics: state.documentPullDiagnostics.get(normalizedPath) ?? [],
|
|
2007
|
+
contentHash: state.diagnosticBindings.get(normalizedPath)?.contentHash,
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
else if (outcome.status === "unavailable") {
|
|
2011
|
+
// Genuinely unconfirmed — never fabricate a clean result. Recorded
|
|
2012
|
+
// via the shared degradation ledger (AGENTS.md: repeated
|
|
2013
|
+
// degradations use incrementDegradationCount, not a hand-rolled
|
|
2014
|
+
// counter) so a server that keeps racing refresh against sweeps
|
|
2015
|
+
// is visible in aggregate instead of silently absorbed per-file.
|
|
2016
|
+
incrementDegradationCount({
|
|
2017
|
+
kind: "lsp-pull-unconfirmed",
|
|
2018
|
+
subject: state.serverId,
|
|
2019
|
+
reason: "unchanged report with no workspacePullResultCache basis, and the per-file fallback pull was unavailable",
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
// "clean": genuinely clean, correctly absent from `out`.
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
1003
2025
|
if (item.resultId !== undefined) {
|
|
1004
2026
|
state.workspacePullResultCache.set(normalizedPath, {
|
|
1005
2027
|
...prior,
|
|
@@ -1045,6 +2067,27 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
|
|
|
1045
2067
|
return out;
|
|
1046
2068
|
}
|
|
1047
2069
|
catch (err) {
|
|
2070
|
+
if (isPullTimeoutError(err, workspacePullTimeoutMs)) {
|
|
2071
|
+
trackAbandonedPullRequest(state, WORKSPACE_PULL_SCOPE, workspaceRequestPromise);
|
|
2072
|
+
workspacePullAbort.abort();
|
|
2073
|
+
recordPullTimeoutTelemetry({
|
|
2074
|
+
scope: WORKSPACE_PULL_SCOPE,
|
|
2075
|
+
identifier: undefined,
|
|
2076
|
+
effectiveBudgetMs: workspacePullTimeoutMs,
|
|
2077
|
+
hadPreviousResultId: previousResultIds.length > 0,
|
|
2078
|
+
elapsedMs: Date.now() - workspacePullStartedAt,
|
|
2079
|
+
server: state.serverId,
|
|
2080
|
+
});
|
|
2081
|
+
armLateAnswerTelemetry({
|
|
2082
|
+
requestPromise: workspaceRequestPromise,
|
|
2083
|
+
scope: WORKSPACE_PULL_SCOPE,
|
|
2084
|
+
identifier: undefined,
|
|
2085
|
+
subject: WORKSPACE_PULL_SCOPE,
|
|
2086
|
+
requestStartedAt: workspacePullStartedAt,
|
|
2087
|
+
server: state.serverId,
|
|
2088
|
+
settlement: workspacePullSettlement,
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
1048
2091
|
recordPullFailure(state, "workspace/diagnostic", err);
|
|
1049
2092
|
return undefined;
|
|
1050
2093
|
}
|
|
@@ -1052,7 +2095,17 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
|
|
|
1052
2095
|
export async function clientWaitForDiagnostics(state, filePath, timeoutMs, options = {}) {
|
|
1053
2096
|
const normalizedPath = normalizeMapKey(filePath);
|
|
1054
2097
|
const minVersion = options.minVersion;
|
|
1055
|
-
const
|
|
2098
|
+
const pullSettleSource = options.pullSettleSource ?? "pull";
|
|
2099
|
+
// #1531: the freshness gate is PER PATH. `minVersion` is a reading of the
|
|
2100
|
+
// client-global counter, and `diagnosticsVersionsByPath` stores that same
|
|
2101
|
+
// counter's value at each store — the two are on one axis, so comparing them
|
|
2102
|
+
// asks "has a publication landed for THIS file since the baseline?" instead of
|
|
2103
|
+
// "has anything at all landed on this client?". Reading the global counter here
|
|
2104
|
+
// let a sibling file's publication end this file's wait before its own budget
|
|
2105
|
+
// lapsed, which then labelled the outcome row `silent` (reserved for "own
|
|
2106
|
+
// budget lapsed with nothing published") instead of `cut_off`.
|
|
2107
|
+
const hasFreshDiagnostics = () => minVersion === undefined ||
|
|
2108
|
+
diagnosticsVersionForPath(state, normalizedPath) > minVersion;
|
|
1056
2109
|
// Version coherence: a cached push is "stale" only when the server reported
|
|
1057
2110
|
// the document version it computed against AND that version lags the latest
|
|
1058
2111
|
// didChange we sent. This prevents serving diagnostics from a superseded
|
|
@@ -1076,9 +2129,13 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
|
|
|
1076
2129
|
// `hasFreshDiagnostics()`, which is unconditionally true when there is no
|
|
1077
2130
|
// version baseline (`minVersion === undefined`), so a failed pull returned
|
|
1078
2131
|
// 0 and was read as a fresh clean.
|
|
2132
|
+
// #1639: the settle operation's OWN clock — durationMs on the eventual
|
|
2133
|
+
// log record measures from here, never from didOpen (that stays in
|
|
2134
|
+
// metadata.elapsedSinceDidOpenMs, computed separately below).
|
|
2135
|
+
const pullSettleStartedAt = Date.now();
|
|
1079
2136
|
let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
|
|
1080
2137
|
if (outcome.status === "found") {
|
|
1081
|
-
logTypeScriptPullSettle(state, normalizedPath);
|
|
2138
|
+
logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
|
|
1082
2139
|
return;
|
|
1083
2140
|
}
|
|
1084
2141
|
let sawClean = outcome.status === "clean";
|
|
@@ -1099,12 +2156,12 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
|
|
|
1099
2156
|
}
|
|
1100
2157
|
if (options.pullOnly) {
|
|
1101
2158
|
if (outcome.status === "found" || sawClean) {
|
|
1102
|
-
logTypeScriptPullSettle(state, normalizedPath);
|
|
2159
|
+
logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
|
|
1103
2160
|
}
|
|
1104
2161
|
return;
|
|
1105
2162
|
}
|
|
1106
2163
|
if (outcome.status === "found" || sawClean) {
|
|
1107
|
-
logTypeScriptPullSettle(state, normalizedPath);
|
|
2164
|
+
logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
|
|
1108
2165
|
return;
|
|
1109
2166
|
}
|
|
1110
2167
|
}
|
|
@@ -1143,6 +2200,21 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
|
|
|
1143
2200
|
}, timeoutMs);
|
|
1144
2201
|
});
|
|
1145
2202
|
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Queue a watched-files change for a file this client did not learn about
|
|
2205
|
+
* through textDocument/didOpen or didChange — an external bash write/delete,
|
|
2206
|
+
* or any other disk change outside the open-document sync path (#1668).
|
|
2207
|
+
* Shares `handleNotifyOpen`'s per-client debounced queue (#271), so a burst
|
|
2208
|
+
* of external changes coalesces into one notification per debounce window
|
|
2209
|
+
* instead of flooding the server with one per file.
|
|
2210
|
+
*/
|
|
2211
|
+
export function handleNotifyExternalChange(state, filePath, type) {
|
|
2212
|
+
if (!isClientAlive(state))
|
|
2213
|
+
return;
|
|
2214
|
+
const normalizedPath = normalizeMapKey(filePath);
|
|
2215
|
+
const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
|
|
2216
|
+
state.watchQueue.enqueue(uri, type);
|
|
2217
|
+
}
|
|
1146
2218
|
export async function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
|
|
1147
2219
|
if (!isClientAlive(state))
|
|
1148
2220
|
return;
|
|
@@ -1187,19 +2259,21 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
|
|
|
1187
2259
|
state.diagnosticPublicationCounts.set(normalizedPath, 0);
|
|
1188
2260
|
if (!isClientAlive(state))
|
|
1189
2261
|
return;
|
|
1190
|
-
await safeSendNotification(state.connection, "textDocument/didOpen", {
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
2262
|
+
const reopenSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version, text: content } });
|
|
2263
|
+
// #1669 review F7: only mirror the send locally once it actually left
|
|
2264
|
+
// the process — see safeSendNotification's doc comment.
|
|
2265
|
+
if (reopenSent)
|
|
2266
|
+
recordSentContent(state, normalizedPath, version, content);
|
|
1194
2267
|
state.openDocuments.add(normalizedPath);
|
|
1195
2268
|
state.openDocumentUris?.set(normalizedPath, uri);
|
|
1196
2269
|
return;
|
|
1197
2270
|
}
|
|
1198
|
-
await safeSendNotification(state.connection, "textDocument/didChange", {
|
|
2271
|
+
const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
|
|
1199
2272
|
textDocument: { uri, version },
|
|
1200
|
-
contentChanges:
|
|
2273
|
+
contentChanges: buildContentChanges(state, normalizedPath, content),
|
|
1201
2274
|
});
|
|
1202
|
-
|
|
2275
|
+
if (changeSent)
|
|
2276
|
+
recordSentContent(state, normalizedPath, version, content);
|
|
1203
2277
|
return;
|
|
1204
2278
|
}
|
|
1205
2279
|
state.pendingOpens.add(normalizedPath);
|
|
@@ -1232,10 +2306,9 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
|
|
|
1232
2306
|
}
|
|
1233
2307
|
if (!isClientAlive(state))
|
|
1234
2308
|
return;
|
|
1235
|
-
await safeSendNotification(state.connection, "textDocument/didOpen", {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
recordSentContent(state, normalizedPath, 0, content);
|
|
2309
|
+
const openSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version: 0, text: content } });
|
|
2310
|
+
if (openSent)
|
|
2311
|
+
recordSentContent(state, normalizedPath, 0, content);
|
|
1239
2312
|
state.pendingOpens.delete(normalizedPath);
|
|
1240
2313
|
state.openDocuments.add(normalizedPath);
|
|
1241
2314
|
state.closedDocuments?.delete(normalizedPath);
|
|
@@ -1267,13 +2340,19 @@ export async function handleNotifyChange(state, filePath, content) {
|
|
|
1267
2340
|
if (!state.openDocuments.has(normalizedPath)) {
|
|
1268
2341
|
// Safety fallback: keep protocol ordering valid even if caller sends
|
|
1269
2342
|
// didChange before first didOpen for this document.
|
|
1270
|
-
await safeSendNotification(state.connection, "textDocument/didOpen", {
|
|
1271
|
-
textDocument: {
|
|
2343
|
+
const fallbackOpenSent = await safeSendNotification(state.connection, "textDocument/didOpen", {
|
|
2344
|
+
textDocument: {
|
|
2345
|
+
uri,
|
|
2346
|
+
languageId: "plaintext",
|
|
2347
|
+
version: 0,
|
|
2348
|
+
text: content,
|
|
2349
|
+
},
|
|
1272
2350
|
});
|
|
1273
2351
|
state.documentVersions.set(normalizedPath, 0);
|
|
1274
2352
|
state.documentOpenedAt.set(normalizedPath, Date.now());
|
|
1275
2353
|
state.diagnosticPublicationCounts.set(normalizedPath, 0);
|
|
1276
|
-
|
|
2354
|
+
if (fallbackOpenSent)
|
|
2355
|
+
recordSentContent(state, normalizedPath, 0, content);
|
|
1277
2356
|
state.openDocuments.add(normalizedPath);
|
|
1278
2357
|
state.openDocumentUris?.set(normalizedPath, uri);
|
|
1279
2358
|
return;
|
|
@@ -1283,11 +2362,12 @@ export async function handleNotifyChange(state, filePath, content) {
|
|
|
1283
2362
|
// Clear stale diagnostics before sending new content so waitForDiagnostics
|
|
1284
2363
|
// doesn't return immediately with the previous edit's results.
|
|
1285
2364
|
clearDiagnosticsForPath(state, normalizedPath);
|
|
1286
|
-
await safeSendNotification(state.connection, "textDocument/didChange", {
|
|
2365
|
+
const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
|
|
1287
2366
|
textDocument: { uri, version },
|
|
1288
|
-
contentChanges:
|
|
2367
|
+
contentChanges: buildContentChanges(state, normalizedPath, content),
|
|
1289
2368
|
});
|
|
1290
|
-
|
|
2369
|
+
if (changeSent)
|
|
2370
|
+
recordSentContent(state, normalizedPath, version, content);
|
|
1291
2371
|
}
|
|
1292
2372
|
/** Close a document through the same lifecycle path exposed by the client. */
|
|
1293
2373
|
export async function closeDocument(state, filePath) {
|
|
@@ -1298,7 +2378,8 @@ export async function closeDocument(state, filePath) {
|
|
|
1298
2378
|
return;
|
|
1299
2379
|
await safeSendNotification(state.connection, "textDocument/didClose", {
|
|
1300
2380
|
textDocument: {
|
|
1301
|
-
uri: state.openDocumentUris?.get(normalizedPath) ??
|
|
2381
|
+
uri: state.openDocumentUris?.get(normalizedPath) ??
|
|
2382
|
+
pathToFileURL(filePath).href,
|
|
1302
2383
|
},
|
|
1303
2384
|
});
|
|
1304
2385
|
state.openDocuments.delete(normalizedPath);
|
|
@@ -1314,7 +2395,67 @@ export async function closeDocument(state, filePath) {
|
|
|
1314
2395
|
state.projectIdentityProbedFiles?.delete(normalizedPath);
|
|
1315
2396
|
clearDiagnosticsForPath(state, normalizedPath);
|
|
1316
2397
|
}
|
|
1317
|
-
|
|
2398
|
+
/**
|
|
2399
|
+
* #1620 residual review F1: `fast` and `processExiting` only ever escalate —
|
|
2400
|
+
* a `true` demands strictly less work (or none) than a `false`, never more.
|
|
2401
|
+
* `existing` covers `requested` when it is at least as demanding on BOTH
|
|
2402
|
+
* axes, so deduping onto it can't silently downgrade what the new caller
|
|
2403
|
+
* needs (e.g. a `processExiting` caller must never inherit a teardown that
|
|
2404
|
+
* might still spawn `taskkill`).
|
|
2405
|
+
*/
|
|
2406
|
+
function isAtLeastAsAggressiveShutdown(existing, requested) {
|
|
2407
|
+
return (!!existing.fast >= !!requested.fast &&
|
|
2408
|
+
!!existing.processExiting >= !!requested.processExiting);
|
|
2409
|
+
}
|
|
2410
|
+
export function clientShutdown(state, options = {}) {
|
|
2411
|
+
// #1620 residual 3: idempotent across racing callers — see the doc comment
|
|
2412
|
+
// on `LSPClientState.shutdownPromise`. The FIRST call starts the real
|
|
2413
|
+
// teardown and stores its promise; every subsequent call (any of the 8
|
|
2414
|
+
// call sites, on the same state) whose options are no MORE aggressive
|
|
2415
|
+
// awaits that same promise instead of running the RPC handshake and
|
|
2416
|
+
// emitting `lsp_client_shutdown` again.
|
|
2417
|
+
//
|
|
2418
|
+
// #1620 residual review F1: a call whose options ARE more aggressive does
|
|
2419
|
+
// NOT dedupe — it starts (and takes over as) its own teardown instead.
|
|
2420
|
+
// The concrete failure this closes: `resetLSPService`'s session-exit path
|
|
2421
|
+
// (`index.ts`) calls `shutdown({fast:true, processExiting:true})` — the
|
|
2422
|
+
// event loop is already closing, and `processExiting` exists precisely so
|
|
2423
|
+
// this teardown never spawns `taskkill` (a closing-loop `uv_async_send`
|
|
2424
|
+
// hard-aborts, `src\win\async.c`). If a GRACEFUL teardown (default
|
|
2425
|
+
// options — e.g. `client_ceiling_lru` eviction) is already in flight on
|
|
2426
|
+
// this same client when the exit path arrives, blindly deduping would
|
|
2427
|
+
// make the exit path inherit that graceful run's own `taskkill` spawn and
|
|
2428
|
+
// its close-event wait — the exact hazard `processExiting` forbids, on
|
|
2429
|
+
// top of blocking the closing loop for however long that takes. The
|
|
2430
|
+
// weaker in-flight teardown is left to finish on its own (every step
|
|
2431
|
+
// downstream — dispose, kill, deregister — is already idempotent).
|
|
2432
|
+
if (state.shutdownPromise &&
|
|
2433
|
+
state.shutdownOptions &&
|
|
2434
|
+
isAtLeastAsAggressiveShutdown(state.shutdownOptions, options)) {
|
|
2435
|
+
return state.shutdownPromise;
|
|
2436
|
+
}
|
|
2437
|
+
state.shutdownOptions = options;
|
|
2438
|
+
const attempt = clientShutdownOnce(state, options);
|
|
2439
|
+
state.shutdownPromise = attempt;
|
|
2440
|
+
// #1620 residual review F3: a REJECTED teardown (e.g. `killProcessTree`
|
|
2441
|
+
// throwing something its own internal catches don't cover) must not
|
|
2442
|
+
// latch a permanently-rejected promise here — every later call, even one
|
|
2443
|
+
// with equal or weaker options, would dedupe onto it forever and never
|
|
2444
|
+
// retry, so the child leaks silently with no further kill/dispose/
|
|
2445
|
+
// deregister attempt. Clear the latch on rejection (only if nothing more
|
|
2446
|
+
// aggressive has already superseded it) so the NEXT call starts a fresh
|
|
2447
|
+
// attempt instead of re-handing back the same dead promise. The rejection
|
|
2448
|
+
// itself still propagates to whoever is awaiting `attempt` right now —
|
|
2449
|
+
// this only affects callers that haven't asked yet.
|
|
2450
|
+
attempt.catch(() => {
|
|
2451
|
+
if (state.shutdownPromise === attempt) {
|
|
2452
|
+
state.shutdownPromise = undefined;
|
|
2453
|
+
state.shutdownOptions = undefined;
|
|
2454
|
+
}
|
|
2455
|
+
});
|
|
2456
|
+
return attempt;
|
|
2457
|
+
}
|
|
2458
|
+
async function clientShutdownOnce(state, options) {
|
|
1318
2459
|
const shutdownStart = Date.now();
|
|
1319
2460
|
state.shutdownRequested = true;
|
|
1320
2461
|
state.isConnected = false;
|
|
@@ -1334,53 +2475,124 @@ export async function clientShutdown(state, options = {}) {
|
|
|
1334
2475
|
state.watchQueue?.cancel();
|
|
1335
2476
|
state.diagnosticEmitter.removeAllListeners();
|
|
1336
2477
|
let shutdownRequestTimedOut = false;
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
2478
|
+
let exitNotifyTimedOut = false;
|
|
2479
|
+
// #1620 residual 1 / residual-review F2: a failure that ISN'T the timer
|
|
2480
|
+
// winning was previously folded into the same "*TimedOut" flag as a real
|
|
2481
|
+
// timeout — honest for the rolled-up `shutdownOutcome` (still "forced"
|
|
2482
|
+
// either way), imprecise about WHICH failure happened. Two shapes land
|
|
2483
|
+
// here, both meaning "no confirmation this reached the server":
|
|
2484
|
+
// (a) a genuine promise rejection that isn't the timer (an immediate
|
|
2485
|
+
// protocol error) — reaches the `catch` below.
|
|
2486
|
+
// (b) `safeSendRequest`/`safeSendNotification` SWALLOW a stream error
|
|
2487
|
+
// (EPIPE, disposed connection — `isStreamError`) and RESOLVE
|
|
2488
|
+
// instead of rejecting, so the `catch` never runs at all. A real
|
|
2489
|
+
// `shutdown` reply is `null`, never `undefined`, and a real `exit`
|
|
2490
|
+
// notify send returns `true`; `undefined`/`false` is the swallow
|
|
2491
|
+
// case. Without this, a coded EPIPE reported `shutdownOutcome:
|
|
2492
|
+
// "graceful"` — nothing was delivered, but the log called it clean.
|
|
2493
|
+
let shutdownRequestUndelivered = false;
|
|
2494
|
+
let exitNotifyUndelivered = false;
|
|
2495
|
+
// #1620: the graceful handshake is BEST-EFFORT and the teardown is not. A
|
|
2496
|
+
// teardown path must not depend on the health of the thing it is tearing
|
|
2497
|
+
// down — the breaker fires exactly when the server is unresponsive. Keep the
|
|
2498
|
+
// handshake inside the `try` and every irreversible teardown step in the
|
|
2499
|
+
// `finally`, so disposal, the record, the deregistration, and the kill run no
|
|
2500
|
+
// matter how the two writes behave, and so a future added await here cannot
|
|
2501
|
+
// reintroduce the class.
|
|
2502
|
+
try {
|
|
2503
|
+
if (!options.fast) {
|
|
2504
|
+
try {
|
|
2505
|
+
const shutdownAck = await withTimeout(safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS);
|
|
2506
|
+
if (shutdownAck === undefined)
|
|
2507
|
+
shutdownRequestUndelivered = true;
|
|
2508
|
+
}
|
|
2509
|
+
catch (err) {
|
|
2510
|
+
/* ignore — proceed to exit/kill so shutdown cannot hang the session */
|
|
2511
|
+
if (isShutdownTimeoutError(err, SHUTDOWN_REQUEST_TIMEOUT_MS)) {
|
|
2512
|
+
shutdownRequestTimedOut = true;
|
|
2513
|
+
}
|
|
2514
|
+
else {
|
|
2515
|
+
shutdownRequestUndelivered = true;
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
try {
|
|
2519
|
+
const exitSent = await withTimeout(safeSendNotification(state.connection, "exit", {}), EXIT_NOTIFY_TIMEOUT_MS);
|
|
2520
|
+
if (exitSent === false)
|
|
2521
|
+
exitNotifyUndelivered = true;
|
|
2522
|
+
}
|
|
2523
|
+
catch (err) {
|
|
2524
|
+
/* ignore — same reason as the request above */
|
|
2525
|
+
if (isShutdownTimeoutError(err, EXIT_NOTIFY_TIMEOUT_MS)) {
|
|
2526
|
+
exitNotifyTimedOut = true;
|
|
2527
|
+
}
|
|
2528
|
+
else {
|
|
2529
|
+
exitNotifyUndelivered = true;
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
1350
2532
|
}
|
|
1351
2533
|
}
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
type: "phase",
|
|
1356
|
-
phase: "lsp_client_shutdown",
|
|
1357
|
-
filePath: state.root,
|
|
1358
|
-
durationMs: Date.now() - shutdownStart,
|
|
1359
|
-
metadata: {
|
|
1360
|
-
serverId: state.serverId,
|
|
1361
|
-
pid,
|
|
1362
|
-
fast: !!options.fast,
|
|
1363
|
-
processExiting: !!options.processExiting,
|
|
1364
|
-
shutdownRequestTimedOut,
|
|
1365
|
-
},
|
|
1366
|
-
});
|
|
1367
|
-
// #449/#472: deregister this LSP child from the instance registry. Fire-
|
|
1368
|
-
// and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
|
|
1369
|
-
// including the `processExiting` path where the event loop is closing
|
|
1370
|
-
// (#234 forbids spawning here, but a plain fs write/rename is fine; even
|
|
1371
|
-
// so, we don't await it to keep this teardown path as fast as before).
|
|
1372
|
-
void removeLspChild(pid).catch((err) => {
|
|
2534
|
+
finally {
|
|
2535
|
+
disposeClientConnection(state);
|
|
2536
|
+
const pid = state.lspProcess.pid;
|
|
1373
2537
|
logLatency({
|
|
1374
2538
|
type: "phase",
|
|
1375
|
-
phase: "
|
|
1376
|
-
filePath:
|
|
1377
|
-
durationMs:
|
|
1378
|
-
metadata: {
|
|
2539
|
+
phase: "lsp_client_shutdown",
|
|
2540
|
+
filePath: state.root,
|
|
2541
|
+
durationMs: Date.now() - shutdownStart,
|
|
2542
|
+
metadata: {
|
|
2543
|
+
serverId: state.serverId,
|
|
2544
|
+
pid,
|
|
2545
|
+
fast: !!options.fast,
|
|
2546
|
+
processExiting: !!options.processExiting,
|
|
2547
|
+
shutdownRequestTimedOut,
|
|
2548
|
+
// #1620: `shutdownRequestTimedOut` covered only the request half, so
|
|
2549
|
+
// a hung exit notify was indistinguishable from a clean exit — and
|
|
2550
|
+
// pre-fix no record was emitted at all. Report both halves plus one
|
|
2551
|
+
// rolled-up verdict, so a forced teardown is countable from the log.
|
|
2552
|
+
exitNotifyTimedOut,
|
|
2553
|
+
// #1620 residual 1 / residual-review F2: the *Undelivered pair covers
|
|
2554
|
+
// the failure the *TimedOut pair used to also claim — a rejection
|
|
2555
|
+
// that is NOT the timer winning, OR (the F2 gap) a swallowed stream
|
|
2556
|
+
// error that `safeSendRequest`/`safeSendNotification` resolved
|
|
2557
|
+
// instead of rejecting, so no exception ever reached the catches
|
|
2558
|
+
// above. Both still roll into shutdownOutcome "forced" below — an
|
|
2559
|
+
// undelivered write is never reported as "graceful".
|
|
2560
|
+
shutdownRequestUndelivered,
|
|
2561
|
+
exitNotifyUndelivered,
|
|
2562
|
+
shutdownOutcome: options.fast
|
|
2563
|
+
? "fast"
|
|
2564
|
+
: shutdownRequestTimedOut ||
|
|
2565
|
+
exitNotifyTimedOut ||
|
|
2566
|
+
shutdownRequestUndelivered ||
|
|
2567
|
+
exitNotifyUndelivered
|
|
2568
|
+
? "forced"
|
|
2569
|
+
: "graceful",
|
|
2570
|
+
},
|
|
1379
2571
|
});
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
2572
|
+
// #449/#472: deregister this LSP child from the instance registry. Fire-
|
|
2573
|
+
// and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
|
|
2574
|
+
// including the `processExiting` path where the event loop is closing
|
|
2575
|
+
// (#234 forbids spawning here, but a plain fs write/rename is fine; even
|
|
2576
|
+
// so, we don't await it to keep this teardown path as fast as before).
|
|
2577
|
+
// #1724: pass this spawn's marker so removeLspChild can guard against a
|
|
2578
|
+
// pid-recycling window (a NEW child claiming this exact pid before this
|
|
2579
|
+
// fire-and-forget write lands) rather than removing whichever child
|
|
2580
|
+
// currently sits at this pid.
|
|
2581
|
+
void removeLspChild(pid, extractSpawnMarker(state.lspProcess.args)).catch((err) => {
|
|
2582
|
+
logLatency({
|
|
2583
|
+
type: "phase",
|
|
2584
|
+
phase: "lsp_registry_write_failed",
|
|
2585
|
+
filePath: "",
|
|
2586
|
+
durationMs: 0,
|
|
2587
|
+
metadata: { op: "remove", pid, error: String(err) },
|
|
2588
|
+
});
|
|
2589
|
+
});
|
|
2590
|
+
// On Windows, killing the direct child first can orphan grandchildren before
|
|
2591
|
+
// taskkill can traverse the tree. Kill the full tree first and wait briefly.
|
|
2592
|
+
// Safe and idempotent on an already-exited child: killProcessTree returns
|
|
2593
|
+
// early on an observed exit rather than signalling a possibly-recycled pid.
|
|
2594
|
+
await killProcessTree(state.lspProcess.process, pid, options);
|
|
2595
|
+
}
|
|
1384
2596
|
}
|
|
1385
2597
|
/**
|
|
1386
2598
|
* Translate a caller-supplied (UTF-16) `(line, character)` into the position the
|
|
@@ -1429,7 +2641,30 @@ signal = getAmbientAbortSignal()) {
|
|
|
1429
2641
|
const requestVersion = normalizedPath !== undefined
|
|
1430
2642
|
? state.documentVersions.get(normalizedPath)
|
|
1431
2643
|
: undefined;
|
|
1432
|
-
|
|
2644
|
+
// #1716: captured OUTSIDE withTimeout, mirroring #1713's pull-diagnostic
|
|
2645
|
+
// fix (`pullDiagnosticSource`), so a timeout's catch below still holds a
|
|
2646
|
+
// live handle on the request for the late-answer watch. `withTimeout`
|
|
2647
|
+
// only races it against a timer — the request keeps running server-side
|
|
2648
|
+
// either way, and without this handle an abandoned answer would settle
|
|
2649
|
+
// into the void with no way to observe it.
|
|
2650
|
+
const requestStartedAt = Date.now();
|
|
2651
|
+
const requestPromise = safeSendRequest(state.connection, method, params, signal);
|
|
2652
|
+
const result = (await withTimeout(requestPromise, timeoutMs).catch((err) => {
|
|
2653
|
+
if (isNavTimeoutError(err, timeoutMs)) {
|
|
2654
|
+
recordNavTimeoutTelemetry({
|
|
2655
|
+
method,
|
|
2656
|
+
scope: normalizedPath,
|
|
2657
|
+
budgetMs: timeoutMs,
|
|
2658
|
+
elapsedMs: Date.now() - requestStartedAt,
|
|
2659
|
+
});
|
|
2660
|
+
armNavLateAnswerTelemetry({
|
|
2661
|
+
requestPromise,
|
|
2662
|
+
method,
|
|
2663
|
+
scope: normalizedPath,
|
|
2664
|
+
requestStartedAt,
|
|
2665
|
+
});
|
|
2666
|
+
return undefined;
|
|
2667
|
+
}
|
|
1433
2668
|
if (err instanceof Error && err.message.startsWith("Timeout after")) {
|
|
1434
2669
|
return undefined;
|
|
1435
2670
|
}
|
|
@@ -1447,13 +2682,188 @@ signal = getAmbientAbortSignal()) {
|
|
|
1447
2682
|
}
|
|
1448
2683
|
return result;
|
|
1449
2684
|
}
|
|
2685
|
+
/**
|
|
2686
|
+
* #1716: `withTimeout` rejects with this EXACT message (`clients/deadline-
|
|
2687
|
+
* utils.ts`) only when its own timer wins the race — mirrors #1713's
|
|
2688
|
+
* `isPullTimeoutError`. Only the timer-won case is a "the answer might still
|
|
2689
|
+
* be coming" situation worth a late-answer watch; a request that already
|
|
2690
|
+
* failed fast (or a stale-message coincidence) has nothing further to
|
|
2691
|
+
* observe. `navRequest`'s own behavior still falls back to the looser
|
|
2692
|
+
* `startsWith` check afterward, unchanged, so this stricter check only gates
|
|
2693
|
+
* telemetry — never the existing timeout-handling behavior.
|
|
2694
|
+
*/
|
|
2695
|
+
function isNavTimeoutError(err, timeoutMs) {
|
|
2696
|
+
return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
|
|
2697
|
+
}
|
|
2698
|
+
/**
|
|
2699
|
+
* #1620 residual 1: `withTimeout` rejects with this EXACT message only when
|
|
2700
|
+
* its own timer wins the race — mirrors #1713's `isPullTimeoutError` and
|
|
2701
|
+
* #1716's `isNavTimeoutError`. `clientShutdown`'s two catches previously
|
|
2702
|
+
* treated ANY rejection (a real timeout OR an immediate protocol error) as
|
|
2703
|
+
* "timed out"; honest for the rolled-up `shutdownOutcome` (both are
|
|
2704
|
+
* "forced"), imprecise for the per-field flag a log reader keys on.
|
|
2705
|
+
*/
|
|
2706
|
+
function isShutdownTimeoutError(err, timeoutMs) {
|
|
2707
|
+
return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
|
|
2708
|
+
}
|
|
2709
|
+
// #1716: subject/scope used when a nav request has no `staleCheckPath` (e.g.
|
|
2710
|
+
// workspaceSymbol, call-hierarchy follow-ups) — mirrors WORKSPACE_PULL_SCOPE
|
|
2711
|
+
// above for the same reason: telemetry identity needs a stable placeholder
|
|
2712
|
+
// where there is no real path.
|
|
2713
|
+
const NAV_REQUEST_NO_PATH_SCOPE = "*no-path*";
|
|
2714
|
+
/**
|
|
2715
|
+
* #1716: `navRequest` is the highest-volume LSP call site — hover,
|
|
2716
|
+
* definition, references, signatureHelp, documentSymbol, workspace/symbol,
|
|
2717
|
+
* call hierarchy all route through it, often several times per turn. A stuck
|
|
2718
|
+
* server can storm timeouts across many methods and files far faster than
|
|
2719
|
+
* the pull-diagnostic path #1713 fixed, so a per-event latency.log write
|
|
2720
|
+
* (that fix's shape) would itself become the flood. Every timeout is still
|
|
2721
|
+
* counted exactly, via the degradation ledger (bounded, in-memory, no I/O),
|
|
2722
|
+
* but only the RISING EDGE — the first occurrence per (method, file) this
|
|
2723
|
+
* session — also pays for a detailed log write. `incrementDegradationCount`'s
|
|
2724
|
+
* return value (`true` on the first occurrence for a kind/subject pair) is
|
|
2725
|
+
* reused as that rising-edge gate instead of a second hand-rolled latch: it
|
|
2726
|
+
* already re-arms at session_start via `resetDegradationLedger`, so this
|
|
2727
|
+
* adds zero new module state. Never throws: telemetry must not perturb a
|
|
2728
|
+
* path that has already decided to return `undefined` regardless.
|
|
2729
|
+
*/
|
|
2730
|
+
function recordNavTimeoutTelemetry(args) {
|
|
2731
|
+
// #1743: `risingEdgePer: "identity"` is the same gate this site hand-rolled
|
|
2732
|
+
// off `incrementDegradationCount`'s return value, now expressed as an
|
|
2733
|
+
// option. The ledger still counts every timeout exactly, per (method, file).
|
|
2734
|
+
emitBounded("lsp_nav_request_timeout", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
|
|
2735
|
+
type: "phase",
|
|
2736
|
+
filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
|
|
2737
|
+
durationMs: args.elapsedMs,
|
|
2738
|
+
metadata: { method: args.method, effectiveBudgetMs: args.budgetMs },
|
|
2739
|
+
}, {
|
|
2740
|
+
ledgerKind: "lsp-nav-request-timeout",
|
|
2741
|
+
risingEdgePer: "identity",
|
|
2742
|
+
reason: `timeout budget=${args.budgetMs}ms elapsed=${args.elapsedMs}ms`,
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
/**
|
|
2746
|
+
* #1716: nav-request sibling of #1713's `armLateAnswerTelemetry`. A
|
|
2747
|
+
* telemetry-only continuation on the ALREADY-abandoned request promise —
|
|
2748
|
+
* purely observational, never touches `state`, and attaching `.then` here
|
|
2749
|
+
* creates no new timer, socket, or other handle, so it cannot keep the
|
|
2750
|
+
* process alive a moment longer than the pending request already does.
|
|
2751
|
+
*
|
|
2752
|
+
* Bounded the same way as the timeout record above: every late answer is
|
|
2753
|
+
* counted exactly via the ledger, but only the rising edge per (method,
|
|
2754
|
+
* file) also writes an `lsp_nav_late_answer_discarded` record.
|
|
2755
|
+
*
|
|
2756
|
+
* Nav answers are read-once — unlike diagnostics' persistent pull cache,
|
|
2757
|
+
* there is no stale value this could poison — so this exists purely for
|
|
2758
|
+
* observability: telling a dogfood session "truly hung" apart from
|
|
2759
|
+
* "answered late", the same distinction #1713 made possible for pulls.
|
|
2760
|
+
*/
|
|
2761
|
+
function armNavLateAnswerTelemetry(args) {
|
|
2762
|
+
args.requestPromise.then(() => {
|
|
2763
|
+
try {
|
|
2764
|
+
const elapsedMs = Date.now() - args.requestStartedAt;
|
|
2765
|
+
// #1743: same rising-edge gate as the timeout record above, now
|
|
2766
|
+
// carried by the helper instead of hand-rolled here.
|
|
2767
|
+
emitBounded("lsp_nav_late_answer_discarded", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
|
|
2768
|
+
type: "phase",
|
|
2769
|
+
filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
|
|
2770
|
+
durationMs: elapsedMs,
|
|
2771
|
+
metadata: { method: args.method },
|
|
2772
|
+
}, {
|
|
2773
|
+
ledgerKind: "lsp-nav-late-answer",
|
|
2774
|
+
risingEdgePer: "identity",
|
|
2775
|
+
reason: `late nav answer discarded after ${elapsedMs}ms`,
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
catch {
|
|
2779
|
+
// Telemetry must never break the observed path.
|
|
2780
|
+
}
|
|
2781
|
+
}, () => {
|
|
2782
|
+
// The abandoned request eventually failed rather than answering —
|
|
2783
|
+
// not an "answer discarded" event. Nothing more to record here; this
|
|
2784
|
+
// handler exists so the request's eventual rejection never surfaces
|
|
2785
|
+
// as an unhandled rejection.
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* #1969: pick the request the liveness probe may send to THIS server.
|
|
2790
|
+
*
|
|
2791
|
+
* The probe only needs a round-trip, so any request works — but it must be one
|
|
2792
|
+
* the server advertised. #1277 originally hardcoded `workspace/symbol` because
|
|
2793
|
+
* it needs no open document, and treated `MethodNotFound` as proof of life.
|
|
2794
|
+
* That rationale is sound for the round-trip, and wrong as a default: a server
|
|
2795
|
+
* that does not implement the method logs an error for every probe. ast-grep's
|
|
2796
|
+
* tower_lsp backend wrote "got a 'workspace/symbol' request, but it is not
|
|
2797
|
+
* implemented" on each one, and its `code=1` deaths clustered after them
|
|
2798
|
+
* (#1969). pi-lens must not keep pushing an unimplemented method at a server
|
|
2799
|
+
* just to learn it is awake.
|
|
2800
|
+
*
|
|
2801
|
+
* The ladder, best first:
|
|
2802
|
+
* 1. `workspace/symbol` when `workspaceSymbolProvider` is advertised — the
|
|
2803
|
+
* #1277 probe, unchanged, and still needs no open document.
|
|
2804
|
+
* 2. `textDocument/documentSymbol` on an already-open document when
|
|
2805
|
+
* `documentSymbolProvider` is advertised. Read-only, scoped to a document
|
|
2806
|
+
* the server already has, and cheap.
|
|
2807
|
+
* 3. `textDocument/hover` at the start of an already-open document when
|
|
2808
|
+
* `hoverProvider` is advertised. This step is what keeps ast-grep covered:
|
|
2809
|
+
* `docs/servercapabilities.md:53` records its advertised set as
|
|
2810
|
+
* codeActionProvider, executeCommandProvider, hoverProvider and
|
|
2811
|
+
* textDocumentSync — no symbol provider of either kind. #1714's own
|
|
2812
|
+
* measurement (clients/lsp/index.ts, `paceAuxNotify`) timed hover against
|
|
2813
|
+
* the real ast-grep binary at 1 ms idle and 2086 ms behind 30 `didOpen`s,
|
|
2814
|
+
* the same ordering proof it recorded for `workspace/symbol`. So the
|
|
2815
|
+
* notify throttle keeps a real barrier here, not a hollow one.
|
|
2816
|
+
* 4. Nothing. The caller then reports liveness from process and connection
|
|
2817
|
+
* state alone, and records that it did.
|
|
2818
|
+
*
|
|
2819
|
+
* `MethodNotFound`-counts-as-alive still holds for every probe: dynamic
|
|
2820
|
+
* registration can retract a capability after `initialize`, and an error reply
|
|
2821
|
+
* is still a reply. What changes is that pi-lens no longer PROVOKES that error
|
|
2822
|
+
* on every probe.
|
|
2823
|
+
*/
|
|
2824
|
+
function chooseLivenessProbe(state) {
|
|
2825
|
+
if (state.operationSupport?.workspaceSymbol === true) {
|
|
2826
|
+
return {
|
|
2827
|
+
method: "workspace/symbol",
|
|
2828
|
+
params: { query: LIVENESS_PING_QUERY },
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
const openUri = firstOpenDocumentUri(state);
|
|
2832
|
+
if (openUri === undefined)
|
|
2833
|
+
return undefined;
|
|
2834
|
+
if (state.operationSupport?.documentSymbol === true) {
|
|
2835
|
+
return {
|
|
2836
|
+
method: "textDocument/documentSymbol",
|
|
2837
|
+
params: { textDocument: { uri: openUri } },
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
if (state.operationSupport?.hover === true) {
|
|
2841
|
+
return {
|
|
2842
|
+
method: "textDocument/hover",
|
|
2843
|
+
params: {
|
|
2844
|
+
textDocument: { uri: openUri },
|
|
2845
|
+
position: { line: 0, character: 0 },
|
|
2846
|
+
},
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
return undefined;
|
|
2850
|
+
}
|
|
2851
|
+
/** URI of any document this client currently holds open, if it holds one. */
|
|
2852
|
+
function firstOpenDocumentUri(state) {
|
|
2853
|
+
for (const normalizedPath of state.openDocuments) {
|
|
2854
|
+
const uri = state.openDocumentUris?.get(normalizedPath);
|
|
2855
|
+
if (uri !== undefined)
|
|
2856
|
+
return uri;
|
|
2857
|
+
}
|
|
2858
|
+
return undefined;
|
|
2859
|
+
}
|
|
1450
2860
|
// #1277: cheap liveness round-trip used by the silent-clean gates in
|
|
1451
2861
|
// `index.ts`. `isAlive()`/`checkAlive()` only look at process/connection
|
|
1452
2862
|
// state — a server that accepted the notify write and then wedged (still
|
|
1453
2863
|
// running, connection still open, just never replying) reads as "alive" by
|
|
1454
2864
|
// those checks even though it will never answer anything again. This sends a
|
|
1455
|
-
// real request (`
|
|
1456
|
-
//
|
|
2865
|
+
// real request (see `chooseLivenessProbe` for which, #1969) and reports
|
|
2866
|
+
// whether the connection round-tripped it — success, a genuine
|
|
1457
2867
|
// protocol-level error (e.g. MethodNotFound), and a stream-destroyed/
|
|
1458
2868
|
// cancelled response (safeSendRequest swallows those to `undefined`, so the
|
|
1459
2869
|
// final `isClientAlive` re-check is what catches "died mid-flight") ALL count
|
|
@@ -1463,10 +2873,30 @@ signal = getAmbientAbortSignal()) {
|
|
|
1463
2873
|
async function clientPingLiveness(state, timeoutMs = LIVENESS_PING_TIMEOUT_MS) {
|
|
1464
2874
|
if (!isClientAlive(state))
|
|
1465
2875
|
return false;
|
|
2876
|
+
const probe = chooseLivenessProbe(state);
|
|
2877
|
+
if (probe === undefined) {
|
|
2878
|
+
// #1969: no advertised method to probe with, so this answer comes from
|
|
2879
|
+
// process and connection state alone — exactly the checks the probe
|
|
2880
|
+
// exists to strengthen. Say so in the ledger rather than let a weaker
|
|
2881
|
+
// verdict pass as the strong one. Rising edge per server, via the
|
|
2882
|
+
// ledger's own tally; the count carries the repeats.
|
|
2883
|
+
try {
|
|
2884
|
+
incrementDegradationCount({
|
|
2885
|
+
kind: "lsp-liveness-probe-unsupported",
|
|
2886
|
+
subject: state.serverId,
|
|
2887
|
+
reason: "no advertised request method to probe with (no " +
|
|
2888
|
+
"workspaceSymbolProvider, and no open document with " +
|
|
2889
|
+
"documentSymbolProvider or hoverProvider); liveness from " +
|
|
2890
|
+
"process and connection state only",
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
catch {
|
|
2894
|
+
// Telemetry must never break the observed path.
|
|
2895
|
+
}
|
|
2896
|
+
return isClientAlive(state);
|
|
2897
|
+
}
|
|
1466
2898
|
try {
|
|
1467
|
-
await withTimeout(safeSendRequest(state.connection,
|
|
1468
|
-
query: LIVENESS_PING_QUERY,
|
|
1469
|
-
}), timeoutMs);
|
|
2899
|
+
await withTimeout(safeSendRequest(state.connection, probe.method, probe.params), timeoutMs);
|
|
1470
2900
|
}
|
|
1471
2901
|
catch (err) {
|
|
1472
2902
|
if (err instanceof Error && err.message.startsWith("Timeout after")) {
|
|
@@ -1569,10 +2999,14 @@ export async function runReadOnlyServerCommand(state, command, args, timeoutMs =
|
|
|
1569
2999
|
}
|
|
1570
3000
|
function validateWorkspaceEditVersions(state, edit) {
|
|
1571
3001
|
for (const change of edit.documentChanges ?? []) {
|
|
1572
|
-
if (typeof change !== "object" ||
|
|
3002
|
+
if (typeof change !== "object" ||
|
|
3003
|
+
change === null ||
|
|
3004
|
+
!("textDocument" in change))
|
|
1573
3005
|
continue;
|
|
1574
3006
|
const textDocument = change.textDocument;
|
|
1575
|
-
if (!textDocument ||
|
|
3007
|
+
if (!textDocument ||
|
|
3008
|
+
typeof textDocument.uri !== "string" ||
|
|
3009
|
+
textDocument.version == null)
|
|
1576
3010
|
continue;
|
|
1577
3011
|
const current = state.documentVersions.get(normalizeMapKey(uriToPath(textDocument.uri)));
|
|
1578
3012
|
if (current === undefined || current !== textDocument.version) {
|
|
@@ -1639,7 +3073,10 @@ async function resolveCodeActionBestEffort(state, action) {
|
|
|
1639
3073
|
return action;
|
|
1640
3074
|
const merged = { ...action, ...resolved };
|
|
1641
3075
|
return merged.edit
|
|
1642
|
-
? {
|
|
3076
|
+
? {
|
|
3077
|
+
...merged,
|
|
3078
|
+
edit: await normalizeClientWorkspaceEdit(state, merged.edit),
|
|
3079
|
+
}
|
|
1643
3080
|
: merged;
|
|
1644
3081
|
}
|
|
1645
3082
|
// --- Client Factory ---
|
|
@@ -1736,10 +3173,21 @@ export async function createLSPClient(options) {
|
|
|
1736
3173
|
// for concurrent waitForDiagnostics calls.
|
|
1737
3174
|
const diagnosticEmitter = new EventEmitter();
|
|
1738
3175
|
diagnosticEmitter.setMaxListeners(50);
|
|
3176
|
+
// SAFETY: three fields below are seeded `undefined as unknown as T` because
|
|
3177
|
+
// they cannot be built here. `workspaceDiagnosticsSupport` and
|
|
3178
|
+
// `operationSupport` are derived from the server's `initialize` result, and
|
|
3179
|
+
// `watchQueue`'s flush closure needs `state` itself. Each is assigned
|
|
3180
|
+
// in this same function before `state` reaches any caller:
|
|
3181
|
+
// `state.watchQueue` on the statement immediately after this literal, and
|
|
3182
|
+
// the two capability fields right after the `initialize` handshake
|
|
3183
|
+
// returns. Reorder that and the fields are genuinely `undefined` at their
|
|
3184
|
+
// declared non-optional types.
|
|
1739
3185
|
const state = {
|
|
1740
3186
|
isConnected: true,
|
|
1741
3187
|
isDestroyed: false,
|
|
1742
3188
|
shutdownRequested: false,
|
|
3189
|
+
shutdownPromise: undefined,
|
|
3190
|
+
shutdownOptions: undefined,
|
|
1743
3191
|
exitedAt: undefined,
|
|
1744
3192
|
connectionDisposed: false,
|
|
1745
3193
|
lastError: undefined,
|
|
@@ -1754,11 +3202,15 @@ export async function createLSPClient(options) {
|
|
|
1754
3202
|
documentOpenedAt: new Map(),
|
|
1755
3203
|
diagnosticEmitter,
|
|
1756
3204
|
diagnosticsVersion: 0,
|
|
3205
|
+
diagnosticsVersionsByPath: new Map(),
|
|
1757
3206
|
documentVersions: new Map(),
|
|
1758
3207
|
diagnosticDocVersions: new Map(),
|
|
1759
3208
|
documentContentHashes: new Map(),
|
|
1760
3209
|
diagnosticBindings: new Map(),
|
|
1761
3210
|
pullResultIds: new Map(),
|
|
3211
|
+
documentPullDiagnosticsBySource: new Map(),
|
|
3212
|
+
pullGenerations: new Map(),
|
|
3213
|
+
pullRequestSequences: new Map(),
|
|
1762
3214
|
workspacePullResultCache: new Map(),
|
|
1763
3215
|
openDocuments: new Set(),
|
|
1764
3216
|
closedDocuments: new Set(),
|
|
@@ -1766,10 +3218,14 @@ export async function createLSPClient(options) {
|
|
|
1766
3218
|
pendingOpens: new Set(),
|
|
1767
3219
|
projectIdentityProbedFiles: new Set(),
|
|
1768
3220
|
// these are filled in after initialize — cast to avoid two-phase init
|
|
1769
|
-
workspaceDiagnosticsSupport:
|
|
3221
|
+
workspaceDiagnosticsSupport:
|
|
3222
|
+
// SAFETY: initialize fills this capability before any workspace diagnostic request.
|
|
3223
|
+
undefined,
|
|
3224
|
+
// SAFETY: initialize fills this capability before operation dispatch.
|
|
1770
3225
|
operationSupport: undefined,
|
|
1771
3226
|
staticDiagnosticsMode: "push-only",
|
|
1772
3227
|
positionEncoding: "utf-16",
|
|
3228
|
+
syncKind: TEXT_DOCUMENT_SYNC_KIND_FULL,
|
|
1773
3229
|
dynamicRegistrations: new Map(),
|
|
1774
3230
|
advertisedCommands: new Set(),
|
|
1775
3231
|
serverEditsAllowed: 0,
|
|
@@ -1779,6 +3235,7 @@ export async function createLSPClient(options) {
|
|
|
1779
3235
|
root,
|
|
1780
3236
|
lspProcess,
|
|
1781
3237
|
// two-phase: the flush closure needs `state` (below)
|
|
3238
|
+
// SAFETY: state construction completes before the flush closure can run.
|
|
1782
3239
|
watchQueue: undefined,
|
|
1783
3240
|
};
|
|
1784
3241
|
// #271: batch per-file workspace/didChangeWatchedFiles into one notification
|
|
@@ -1804,6 +3261,18 @@ export async function createLSPClient(options) {
|
|
|
1804
3261
|
}), initializeTimeoutMs);
|
|
1805
3262
|
}
|
|
1806
3263
|
catch (err) {
|
|
3264
|
+
// #1969: claim this kill BEFORE issuing it. `setupConnectionLifecycle`
|
|
3265
|
+
// ran above, so its `exit` and `close` handlers are already armed on
|
|
3266
|
+
// this child, and both read `state.shutdownRequested` to tell a crash
|
|
3267
|
+
// from a teardown we asked for. The kill below is one we asked for.
|
|
3268
|
+
// Without this line it reported as an unprompted death, fabricating a
|
|
3269
|
+
// `lsp-server-unexpected-close` entry reading
|
|
3270
|
+
// "code=1 signal=none stderr=empty" — character for character the
|
|
3271
|
+
// ast-grep signature this issue exists to make trustworthy — every time
|
|
3272
|
+
// a server merely failed to complete its handshake. It also silenced a
|
|
3273
|
+
// false `lsp_server_unexpected_exit` latency line that this path has
|
|
3274
|
+
// been writing since the #615 follow-up.
|
|
3275
|
+
state.shutdownRequested = true;
|
|
1807
3276
|
// Hard-kill the hung process so it doesn't become a zombie.
|
|
1808
3277
|
// SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
|
|
1809
3278
|
const pid = lspProcess.pid;
|
|
@@ -1811,7 +3280,7 @@ export async function createLSPClient(options) {
|
|
|
1811
3280
|
// A child registered above (recordLspChild) but never reaching a healthy
|
|
1812
3281
|
// createLSPClient return must still be deregistered here — otherwise the
|
|
1813
3282
|
// registry keeps a stale entry for a process we just killed.
|
|
1814
|
-
void removeLspChild(pid).catch((err) => {
|
|
3283
|
+
void removeLspChild(pid, extractSpawnMarker(lspProcess.args)).catch((err) => {
|
|
1815
3284
|
// best-effort — a stale registry entry is harmless (the reaper's
|
|
1816
3285
|
// liveness check will find it dead on the next sweep regardless)
|
|
1817
3286
|
logLatency({
|
|
@@ -1872,6 +3341,7 @@ export async function createLSPClient(options) {
|
|
|
1872
3341
|
detectWorkspaceDiagnosticsSupport(initResult);
|
|
1873
3342
|
state.operationSupport = detectOperationSupport(initResult);
|
|
1874
3343
|
state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
|
|
3344
|
+
state.syncKind = negotiateSyncKind(initResult?.capabilities);
|
|
1875
3345
|
state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
|
|
1876
3346
|
{}).sort((a, b) => a.localeCompare(b));
|
|
1877
3347
|
for (const cmd of detectExecuteCommands(initResult)) {
|
|
@@ -1913,6 +3383,9 @@ export async function createLSPClient(options) {
|
|
|
1913
3383
|
async change(filePath, content) {
|
|
1914
3384
|
return handleNotifyChange(state, filePath, content);
|
|
1915
3385
|
},
|
|
3386
|
+
watchedFileChange(filePath, type) {
|
|
3387
|
+
handleNotifyExternalChange(state, filePath, type);
|
|
3388
|
+
},
|
|
1916
3389
|
},
|
|
1917
3390
|
getDiagnostics(filePath) {
|
|
1918
3391
|
return getMergedDiagnosticsForPath(state, normalizeMapKey(filePath));
|
|
@@ -1920,6 +3393,9 @@ export async function createLSPClient(options) {
|
|
|
1920
3393
|
getDiagnosticBinding(filePath) {
|
|
1921
3394
|
return state.diagnosticBindings.get(normalizeMapKey(filePath));
|
|
1922
3395
|
},
|
|
3396
|
+
getDiagnosticsVersionForPath(filePath) {
|
|
3397
|
+
return diagnosticsVersionForPath(state, normalizeMapKey(filePath));
|
|
3398
|
+
},
|
|
1923
3399
|
getAllDiagnostics() {
|
|
1924
3400
|
const result = new Map();
|
|
1925
3401
|
const keys = new Set([
|
|
@@ -1936,10 +3412,12 @@ export async function createLSPClient(options) {
|
|
|
1936
3412
|
return result;
|
|
1937
3413
|
},
|
|
1938
3414
|
getTrackedDiagnosticPaths() {
|
|
1939
|
-
return [
|
|
3415
|
+
return [
|
|
3416
|
+
...new Set([
|
|
1940
3417
|
...state.pushDiagnostics.keys(),
|
|
1941
3418
|
...state.documentPullDiagnostics.keys(),
|
|
1942
|
-
])
|
|
3419
|
+
]),
|
|
3420
|
+
].map((filePath) => process.platform === "win32" ? filePath.replace(/\//g, "\\") : filePath);
|
|
1943
3421
|
},
|
|
1944
3422
|
pruneDiagnostics(predicate) {
|
|
1945
3423
|
let removed = 0;
|
|
@@ -1978,6 +3456,9 @@ export async function createLSPClient(options) {
|
|
|
1978
3456
|
async executeCommand(command, args, mutationContext) {
|
|
1979
3457
|
return runServerCommand(state, command, args, EXECUTE_COMMAND_TIMEOUT_MS, mutationContext);
|
|
1980
3458
|
},
|
|
3459
|
+
async executeReadOnlyCommand(command, args) {
|
|
3460
|
+
return runReadOnlyServerCommand(state, command, args);
|
|
3461
|
+
},
|
|
1981
3462
|
get diagnosticsVersion() {
|
|
1982
3463
|
return state.diagnosticsVersion;
|
|
1983
3464
|
},
|
|
@@ -2143,14 +3624,25 @@ export async function createLSPClient(options) {
|
|
|
2143
3624
|
};
|
|
2144
3625
|
}
|
|
2145
3626
|
// Helper to safely send notifications - catches stream destruction
|
|
3627
|
+
/**
|
|
3628
|
+
* Returns `true` once the notification was actually handed to the transport,
|
|
3629
|
+
* `false` when a stream error was swallowed (connection error handlers will
|
|
3630
|
+
* update state separately). #1669 review F7: callers that mirror what they
|
|
3631
|
+
* just told the server locally (`recordSentContent`) MUST gate on this
|
|
3632
|
+
* return value — recording a send that never left the process would desync
|
|
3633
|
+
* the mirror from what the server actually has, with nothing to self-heal
|
|
3634
|
+
* it (the next Incremental range would be computed against content the
|
|
3635
|
+
* server never saw).
|
|
3636
|
+
*/
|
|
2146
3637
|
async function safeSendNotification(connection, method, params) {
|
|
2147
3638
|
try {
|
|
2148
3639
|
await connection.sendNotification(method, params);
|
|
3640
|
+
return true;
|
|
2149
3641
|
}
|
|
2150
3642
|
catch (err) {
|
|
2151
3643
|
if (isStreamError(err)) {
|
|
2152
3644
|
// Silently ignore - stream was destroyed, connection error handlers will update state
|
|
2153
|
-
return;
|
|
3645
|
+
return false;
|
|
2154
3646
|
}
|
|
2155
3647
|
throw err;
|
|
2156
3648
|
}
|
|
@@ -2162,7 +3654,7 @@ async function safeSendRequest(connection, method, params,
|
|
|
2162
3654
|
// vscode-jsonrpc's CancellationToken → an LSP `$/cancelRequest` notification,
|
|
2163
3655
|
// so a server stops computing a result the agent has already abandoned (#238
|
|
2164
3656
|
// Item 1). The rejection that follows is swallowed (treated as `undefined`).
|
|
2165
|
-
signal) {
|
|
3657
|
+
signal, settlement) {
|
|
2166
3658
|
// Already abandoned before we even sent — don't bother the server.
|
|
2167
3659
|
if (signal?.aborted)
|
|
2168
3660
|
return undefined;
|
|
@@ -2189,9 +3681,13 @@ signal) {
|
|
|
2189
3681
|
return (await send());
|
|
2190
3682
|
}
|
|
2191
3683
|
catch (err) {
|
|
2192
|
-
if (
|
|
2193
|
-
|
|
2194
|
-
|
|
3684
|
+
if (isCancellationError(err)) {
|
|
3685
|
+
if (settlement)
|
|
3686
|
+
settlement.cancelled = true;
|
|
3687
|
+
return undefined;
|
|
3688
|
+
}
|
|
3689
|
+
if (isStreamError(err)) {
|
|
3690
|
+
// Stream destroyed; connection handlers update client state separately.
|
|
2195
3691
|
return undefined;
|
|
2196
3692
|
}
|
|
2197
3693
|
if (isContentModifiedError(err)) {
|