@herbertgao/pi-extensions 2026.8.3 → 2026.8.5
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 +45 -4
- package/examples/pi-footer.json +275 -0
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +10 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +10 -2
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +5 -8
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
- package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +15 -2
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
- package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
- package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
- package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
- package/node_modules/@narumitw/pi-btw/README.md +8 -7
- package/node_modules/@narumitw/pi-btw/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/src/menu.ts +1 -0
- package/node_modules/pi-footer/CHANGELOG.md +145 -0
- package/node_modules/pi-footer/LICENSE +21 -0
- package/node_modules/pi-footer/README.md +354 -0
- package/node_modules/pi-footer/package.json +68 -0
- package/node_modules/pi-footer/src/cache.ts +106 -0
- package/node_modules/pi-footer/src/colors.ts +237 -0
- package/node_modules/pi-footer/src/config.ts +202 -0
- package/node_modules/pi-footer/src/event-widgets.ts +41 -0
- package/node_modules/pi-footer/src/extension-statuses.ts +102 -0
- package/node_modules/pi-footer/src/git.ts +135 -0
- package/node_modules/pi-footer/src/index.ts +268 -0
- package/node_modules/pi-footer/src/metrics.ts +141 -0
- package/node_modules/pi-footer/src/presets.ts +383 -0
- package/node_modules/pi-footer/src/render.ts +114 -0
- package/node_modules/pi-footer/src/separators.ts +52 -0
- package/node_modules/pi-footer/src/types.ts +122 -0
- package/node_modules/pi-footer/src/ui/color-level-confirm.ts +22 -0
- package/node_modules/pi-footer/src/ui/color-options.ts +195 -0
- package/node_modules/pi-footer/src/ui/config-lifecycle.ts +61 -0
- package/node_modules/pi-footer/src/ui/edit-colors.ts +13 -0
- package/node_modules/pi-footer/src/ui/events.ts +35 -0
- package/node_modules/pi-footer/src/ui/extension-status-picker.ts +60 -0
- package/node_modules/pi-footer/src/ui/extension-statuses.ts +73 -0
- package/node_modules/pi-footer/src/ui/fields.ts +252 -0
- package/node_modules/pi-footer/src/ui/global-menu.ts +126 -0
- package/node_modules/pi-footer/src/ui/helpers.ts +44 -0
- package/node_modules/pi-footer/src/ui/layout.ts +17 -0
- package/node_modules/pi-footer/src/ui/line-list.ts +19 -0
- package/node_modules/pi-footer/src/ui/model.ts +64 -0
- package/node_modules/pi-footer/src/ui/navigation.ts +29 -0
- package/node_modules/pi-footer/src/ui/option-edit.ts +93 -0
- package/node_modules/pi-footer/src/ui/overlay-render.ts +103 -0
- package/node_modules/pi-footer/src/ui/screen-context.ts +18 -0
- package/node_modules/pi-footer/src/ui/screen-controller.ts +27 -0
- package/node_modules/pi-footer/src/ui/screen-render.ts +32 -0
- package/node_modules/pi-footer/src/ui/screen-state.ts +20 -0
- package/node_modules/pi-footer/src/ui/screen.ts +243 -0
- package/node_modules/pi-footer/src/ui/screens/add-widget.ts +92 -0
- package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +52 -0
- package/node_modules/pi-footer/src/ui/screens/controller.ts +12 -0
- package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +68 -0
- package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +115 -0
- package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +61 -0
- package/node_modules/pi-footer/src/ui/screens/global.ts +75 -0
- package/node_modules/pi-footer/src/ui/screens/line-list.ts +108 -0
- package/node_modules/pi-footer/src/ui/screens/main.ts +99 -0
- package/node_modules/pi-footer/src/ui/screens/terminal.ts +121 -0
- package/node_modules/pi-footer/src/ui/screens/widget-list.ts +154 -0
- package/node_modules/pi-footer/src/ui/terminal-menu.ts +32 -0
- package/node_modules/pi-footer/src/ui/theme.ts +48 -0
- package/node_modules/pi-footer/src/ui/title-bar.ts +100 -0
- package/node_modules/pi-footer/src/ui/widget-actions.ts +76 -0
- package/node_modules/pi-footer/src/ui.ts +66 -0
- package/node_modules/pi-footer/src/widgets/context.ts +28 -0
- package/node_modules/pi-footer/src/widgets/core/active-tools.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/context-window.ts +24 -0
- package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +19 -0
- package/node_modules/pi-footer/src/widgets/core/cwd.ts +108 -0
- package/node_modules/pi-footer/src/widgets/core/event.ts +34 -0
- package/node_modules/pi-footer/src/widgets/core/external-status.ts +102 -0
- package/node_modules/pi-footer/src/widgets/core/model-provider.ts +20 -0
- package/node_modules/pi-footer/src/widgets/core/model.ts +35 -0
- package/node_modules/pi-footer/src/widgets/core/provider.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/session-name.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/branch.ts +81 -0
- package/node_modules/pi-footer/src/widgets/git/clean.ts +19 -0
- package/node_modules/pi-footer/src/widgets/git/deletions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/diff.ts +38 -0
- package/node_modules/pi-footer/src/widgets/git/insertions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/remote.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/root.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/sha.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/staged.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/status.ts +19 -0
- package/node_modules/pi-footer/src/widgets/git/unstaged.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/untracked.ts +17 -0
- package/node_modules/pi-footer/src/widgets/instance.ts +118 -0
- package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +18 -0
- package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +32 -0
- package/node_modules/pi-footer/src/widgets/layout/separator.ts +57 -0
- package/node_modules/pi-footer/src/widgets/layout/spacer.ts +31 -0
- package/node_modules/pi-footer/src/widgets/options.ts +122 -0
- package/node_modules/pi-footer/src/widgets/project/runtime.ts +361 -0
- package/node_modules/pi-footer/src/widgets/registry.ts +223 -0
- package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/compactions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/elapsed.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/last-activity.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/messages.ts +19 -0
- package/node_modules/pi-footer/src/widgets/session/session-id.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/session-start.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/tool-results.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/total-messages.ts +19 -0
- package/node_modules/pi-footer/src/widgets/session/total-time.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/user-messages.ts +17 -0
- package/node_modules/pi-footer/src/widgets/store.ts +26 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +55 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +108 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +26 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +29 -0
- package/node_modules/pi-footer/src/widgets/tokens/context.ts +29 -0
- package/node_modules/pi-footer/src/widgets/tokens/cost.ts +51 -0
- package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +20 -0
- package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/types.ts +181 -0
- package/node_modules/pi-footer/src/widgets/utils/colors.ts +11 -0
- package/node_modules/pi-footer/src/widgets/utils/context.ts +112 -0
- package/node_modules/pi-footer/src/widgets/utils/session.ts +19 -0
- package/node_modules/pi-footer/src/widgets/utils/token-format.ts +78 -0
- package/node_modules/pi-lens/CHANGELOG.md +2440 -954
- package/node_modules/pi-lens/README.md +13 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
- package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
- package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
- package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
- package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
- package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
- package/node_modules/pi-lens/dist/clients/biome-client.js +29 -25
- package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
- package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
- package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
- package/node_modules/pi-lens/dist/clients/bus-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
- package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
- package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
- package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
- package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
- package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
- package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
- package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/console-guard-install.js +14 -0
- package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
- package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +347 -75
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +61 -11
- package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +63 -20
- package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
- package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
- package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
- package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
- package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1085 -681
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
- package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
- package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +14 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +277 -26
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
- package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
- package/node_modules/pi-lens/dist/clients/extension-log.js +139 -0
- package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
- package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
- package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
- package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
- package/node_modules/pi-lens/dist/clients/format-events-publish.js +16 -3
- package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
- package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
- package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
- package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
- package/node_modules/pi-lens/dist/clients/git-guard.js +681 -11
- package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
- package/node_modules/pi-lens/dist/clients/go-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +8 -0
- package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
- package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
- package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
- package/node_modules/pi-lens/dist/clients/installer/index.js +390 -99
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +31 -54
- package/node_modules/pi-lens/dist/clients/knip-client.js +15 -36
- package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
- package/node_modules/pi-lens/dist/clients/latency-logger.js +23 -1
- package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
- package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
- package/node_modules/pi-lens/dist/clients/lens-events.js +27 -4
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
- package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
- package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +30 -0
- package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/client.js +493 -48
- package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +86 -0
- package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
- package/node_modules/pi-lens/dist/clients/lsp/index.js +1807 -780
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
- package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
- package/node_modules/pi-lens/dist/clients/lsp/server.js +196 -32
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
- package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +123 -32
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
- package/node_modules/pi-lens/dist/clients/mcp/session.js +216 -8
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
- package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
- package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
- package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
- package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
- package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
- package/node_modules/pi-lens/dist/clients/pipeline.js +47 -13
- package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
- package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
- package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
- package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
- package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
- package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
- package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
- package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
- package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
- package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
- package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
- package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
- package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +107 -73
- package/node_modules/pi-lens/dist/clients/runtime-context.js +36 -12
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +74 -6
- package/node_modules/pi-lens/dist/clients/runtime-session.js +405 -117
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +218 -23
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +237 -64
- package/node_modules/pi-lens/dist/clients/rust-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
- package/node_modules/pi-lens/dist/clients/sg-runner.js +284 -174
- package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +224 -0
- package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
- package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
- package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
- package/node_modules/pi-lens/dist/clients/startup-timing.js +1 -1
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +38 -8
- package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
- package/node_modules/pi-lens/dist/clients/tool-policy.js +388 -39
- package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
- package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
- package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
- package/node_modules/pi-lens/dist/clients/warm-attach.js +33 -13
- package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
- package/node_modules/pi-lens/dist/clients/word-index.js +391 -64
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
- package/node_modules/pi-lens/dist/index.js +49361 -36888
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
- package/node_modules/pi-lens/dist/mcp/server.js +211 -44
- package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +200 -36
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +153 -40
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
- package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
- package/node_modules/pi-lens/docs/agent-guide.md +2 -2
- package/node_modules/pi-lens/docs/agent-tools.md +21 -10
- package/node_modules/pi-lens/docs/analysisall.md +192 -0
- package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +5 -2
- package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
- package/node_modules/pi-lens/docs/environment-variables.md +44 -0
- package/node_modules/pi-lens/docs/fable.md +27 -0
- package/node_modules/pi-lens/docs/features.md +31 -2
- package/node_modules/pi-lens/docs/globalconfig.md +45 -1
- package/node_modules/pi-lens/docs/language-coverage.md +4 -3
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
- package/node_modules/pi-lens/docs/mcp.md +37 -4
- package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
- package/node_modules/pi-lens/docs/settings.md +12 -1
- package/node_modules/pi-lens/docs/tools.md +4 -1
- package/node_modules/pi-lens/docs/usage.md +18 -0
- package/node_modules/pi-lens/package.json +10 -10
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
- package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
- package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +55 -2
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +25 -0
- package/node_modules/pi-mcp-adapter/README.md +34 -3
- package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
- package/node_modules/pi-mcp-adapter/commands.ts +15 -7
- package/node_modules/pi-mcp-adapter/direct-tools.ts +54 -7
- package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
- package/node_modules/pi-mcp-adapter/index.ts +8 -3
- package/node_modules/pi-mcp-adapter/init.ts +29 -5
- package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
- package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
- package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
- package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +27 -4
- package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
- package/node_modules/pi-mcp-adapter/package.json +3 -3
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
- package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
- package/node_modules/pi-mcp-adapter/server-manager.ts +36 -7
- package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
- package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
- package/node_modules/pi-mcp-adapter/tool-metadata.ts +42 -4
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
- package/node_modules/pi-mcp-adapter/types.ts +93 -27
- package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
- package/package.json +17 -13
- package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
- package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
- package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
- package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
- package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
- package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
- package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
- package/node_modules/pi-lens/dist/clients/types.js +0 -11
|
@@ -11,25 +11,69 @@ import { createHash } from "node:crypto";
|
|
|
11
11
|
import * as nodeFs from "node:fs";
|
|
12
12
|
import fs from "node:fs/promises";
|
|
13
13
|
import path from "node:path";
|
|
14
|
+
import { pathToFileURL, URL } from "node:url";
|
|
14
15
|
import { getProjectIgnoreMatcher, isExcludedDirName, } from "../file-utils.js";
|
|
15
16
|
import { recordLsp } from "../widget-state.js";
|
|
16
17
|
import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
|
|
17
18
|
import { detectFileRole } from "../file-role.js";
|
|
18
19
|
import { logLatency } from "../latency-logger.js";
|
|
19
20
|
import { logSessionStart } from "../sessionstart-logger.js";
|
|
21
|
+
import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
|
|
22
|
+
import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
|
|
20
23
|
import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
|
|
21
24
|
import { withDeadline } from "../deadline-utils.js";
|
|
22
25
|
import { isAtOrAboveHomeDir, normalizeMapKey, uriToPath, } from "../path-utils.js";
|
|
26
|
+
import { recordLspMutation, } from "../lsp-mutation.js";
|
|
23
27
|
import { createLSPClient } from "./client.js";
|
|
28
|
+
import { bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, } from "./diagnostic-binding.js";
|
|
24
29
|
import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
|
|
25
30
|
import { getLanguageId } from "./language.js";
|
|
26
|
-
import { LSP_SERVERS, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
|
|
31
|
+
import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
|
|
27
32
|
import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
|
|
28
33
|
import { raceToCompletion } from "./aggregation.js";
|
|
29
|
-
import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, } from "./edits.js";
|
|
34
|
+
import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, validateWorkspaceEdit, } from "./edits.js";
|
|
30
35
|
import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "./workspace-diagnostics-cache.js";
|
|
31
36
|
import { attemptTsserverSyncDiagnostics, } from "./tsserver-sync.js";
|
|
32
37
|
import { isWarmAttached, tryWarmAttachedDiagnostics, } from "../warm-attach.js";
|
|
38
|
+
function destinationUriPreservingSpelling(oldUri, oldFilePath, newFilePath) {
|
|
39
|
+
const canonical = pathToFileURL(newFilePath);
|
|
40
|
+
try {
|
|
41
|
+
const original = new URL(oldUri);
|
|
42
|
+
const authority = /^file:\/\/([^/]*)/i.exec(oldUri)?.[1] ?? "";
|
|
43
|
+
if (original.protocol !== "file:" ||
|
|
44
|
+
(original.host !== "" && original.hostname !== "localhost")) {
|
|
45
|
+
return canonical.href;
|
|
46
|
+
}
|
|
47
|
+
const canonicalDestination = () => `${original.protocol}//${authority}${canonical.pathname}`;
|
|
48
|
+
const oldParent = path.resolve(path.dirname(oldFilePath));
|
|
49
|
+
const newParent = path.resolve(path.dirname(newFilePath));
|
|
50
|
+
if (normalizeMapKey(oldParent) !== normalizeMapKey(newParent)) {
|
|
51
|
+
return canonicalDestination();
|
|
52
|
+
}
|
|
53
|
+
const slash = original.pathname.lastIndexOf("/");
|
|
54
|
+
if (slash < 0)
|
|
55
|
+
return canonicalDestination();
|
|
56
|
+
let rawName = canonical.pathname.slice(canonical.pathname.lastIndexOf("/") + 1);
|
|
57
|
+
const oldRawName = original.pathname.slice(slash + 1);
|
|
58
|
+
const oldName = decodeURIComponent(oldRawName);
|
|
59
|
+
const expectedOldName = path.basename(oldFilePath);
|
|
60
|
+
if (oldName.toLowerCase() !== expectedOldName.toLowerCase()) {
|
|
61
|
+
return canonicalDestination();
|
|
62
|
+
}
|
|
63
|
+
// Keep non-canonical percent-encoding choices from the URI that was
|
|
64
|
+
// opened (for example `%2E` instead of `.`) while using the new path's
|
|
65
|
+
// exact basename. The authority and directory spelling are preserved too.
|
|
66
|
+
for (const match of oldRawName.matchAll(/%([0-9a-f]{2})/gi)) {
|
|
67
|
+
const encoded = match[0];
|
|
68
|
+
const decoded = String.fromCharCode(Number.parseInt(match[1], 16));
|
|
69
|
+
rawName = rawName.split(decoded).join(encoded);
|
|
70
|
+
}
|
|
71
|
+
return `${original.protocol}//${authority}${original.pathname.slice(0, slash + 1)}${rawName}`;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return canonical.href;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
33
77
|
// --- Init override helpers ---
|
|
34
78
|
/**
|
|
35
79
|
* Recursively merges `override` onto `base`. Override wins on leaf conflicts
|
|
@@ -69,6 +113,41 @@ export function mergeInitializationOptions(base, override) {
|
|
|
69
113
|
const BROKEN_BASE_COOLDOWN_MS = 15_000;
|
|
70
114
|
const BROKEN_MAX_COOLDOWN_MS = 5 * 60_000; // cap at 5 minutes
|
|
71
115
|
const BROKEN_PERMANENT_AFTER = 5; // disable for session after N consecutive failures
|
|
116
|
+
// #1127: a client that dies THIS soon after a successful spawn/initialize is
|
|
117
|
+
// treated as a crash-loop symptom (opengrep's post-init "Unhandled message"
|
|
118
|
+
// exit), not a legitimate long-running server that happened to restart once.
|
|
119
|
+
// 60s comfortably covers normal serve-a-few-files-then-idle sessions while
|
|
120
|
+
// still catching "spawns fine, dies within seconds every time" servers.
|
|
121
|
+
const RUNTIME_EXIT_UPTIME_THRESHOLD_MS = 60_000;
|
|
122
|
+
// #1142: the fast path above only trips on CONSECUTIVE exits UNDER the 60s
|
|
123
|
+
// threshold, so a server that reliably dies just PAST it (~65-90s after every
|
|
124
|
+
// spawn) resets that streak on every death and churns forever — a persistent
|
|
125
|
+
// low-frequency crash loop the hard-cutoff design structurally cannot see. The
|
|
126
|
+
// windowed-rate trip COMPOSES with (does not replace) the fast path: N
|
|
127
|
+
// non-intentional deaths within a rolling M-minute window trip the breaker
|
|
128
|
+
// regardless of each death's individual lifetime.
|
|
129
|
+
// N = BROKEN_PERMANENT_AFTER (5): one uniform "5 strikes" give-up count
|
|
130
|
+
// across both streams, and high enough that the benign over-threshold
|
|
131
|
+
// deaths that reach this path (a one-off crash after a long healthy run;
|
|
132
|
+
// a single sleep/resume connection drop) — at most one per key per event
|
|
133
|
+
// — cannot approach it.
|
|
134
|
+
// M = 15 min: wide enough for a genuine slow loop (each life ~65-90s, so 5
|
|
135
|
+
// death→respawn cycles land in ~6-15 min of active churn) to accumulate
|
|
136
|
+
// 5, yet short enough that sparse benign crashes (e.g. "twice last hour,
|
|
137
|
+
// now healthy") age out of the rolling window before five ever coincide.
|
|
138
|
+
const RUNTIME_EXIT_WINDOW_MS = 15 * 60_000;
|
|
139
|
+
const RUNTIME_EXIT_WINDOW_TRIP_COUNT = BROKEN_PERMANENT_AFTER;
|
|
140
|
+
// A death whose measured lifetime (`exitedAt - spawnedAt`) exceeds this ceiling
|
|
141
|
+
// is NOT recorded toward the window: it is either a genuinely long healthy run
|
|
142
|
+
// that crashed once, or a lifetime computed ACROSS a machine-sleep / Modern-
|
|
143
|
+
// Standby suspend (the #1122/#1139 death-timestamp lesson — a suspend gap makes
|
|
144
|
+
// a benign exit look like a huge "uptime"), and neither is crash-loop churn.
|
|
145
|
+
// The slow-loop residual this fix targets dies well within the ceiling
|
|
146
|
+
// (~65-90s), so it is unaffected. This is a belt-and-suspenders guard on top of
|
|
147
|
+
// the structural defense that the window is per-KEY: a single suspend kills at
|
|
148
|
+
// most one live client per server, contributing at most one death to any one
|
|
149
|
+
// key's window, so it cannot by itself reach the trip count regardless.
|
|
150
|
+
const RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS = 10 * 60_000;
|
|
72
151
|
// #743: a server whose per-server notify write (didOpen/didChange) times out
|
|
73
152
|
// this many times in a row is a persistently backpressured server; it is demoted
|
|
74
153
|
// into the `broken` cooldown map (evicted + cooled down) so subsequent sweeps
|
|
@@ -81,6 +160,20 @@ const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(0, Number.parseInt(process.env.PI_LE
|
|
|
81
160
|
1500);
|
|
82
161
|
const TOUCH_DEBOUNCE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_TOUCH_DEBOUNCE_MS ?? "1500", 10) ||
|
|
83
162
|
1500);
|
|
163
|
+
const DEFAULT_LSP_CLIENT_CEILING = 24;
|
|
164
|
+
const DEFAULT_TS_IDLE_EVICT_MS = 20 * 60_000;
|
|
165
|
+
export function getTypeScriptIdleEvictMs() {
|
|
166
|
+
const parsed = Number.parseInt(process.env.PI_LENS_TS_IDLE_EVICT_MS ?? "", 10);
|
|
167
|
+
return Number.isSafeInteger(parsed) && parsed > 0
|
|
168
|
+
? parsed
|
|
169
|
+
: DEFAULT_TS_IDLE_EVICT_MS;
|
|
170
|
+
}
|
|
171
|
+
export function getLspClientCeiling() {
|
|
172
|
+
const parsed = Number.parseInt(process.env.PI_LENS_LSP_CLIENT_CEILING ?? "", 10);
|
|
173
|
+
return Number.isSafeInteger(parsed) && parsed > 0
|
|
174
|
+
? parsed
|
|
175
|
+
: DEFAULT_LSP_CLIENT_CEILING;
|
|
176
|
+
}
|
|
84
177
|
// #667: the sweep warm-up round trip's OWN generous, one-time budget —
|
|
85
178
|
// deliberately larger than any single per-file sweep budget (`perFileMs` in
|
|
86
179
|
// `runWorkspaceDiagnostics`, or the batch tool's per-file wait) because this
|
|
@@ -279,7 +372,7 @@ const WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE = (() => {
|
|
|
279
372
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 8;
|
|
280
373
|
})();
|
|
281
374
|
// #584: opengrep has no `workspace/diagnostic` pull support (push-only,
|
|
282
|
-
// docs/servercapabilities.md) and `reopenOnResync: true` (
|
|
375
|
+
// docs/servercapabilities.md) and `reopenOnResync: true` (wait-policy/strategies.ts)
|
|
283
376
|
// means every per-file LSP touch already forces a full re-scan anyway — there's
|
|
284
377
|
// no incremental win from routing it through the sweep's per-file loop. On a
|
|
285
378
|
// full workspace sweep it instead dominates the per-file wait (its own
|
|
@@ -390,11 +483,50 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
|
|
|
390
483
|
export class LSPService {
|
|
391
484
|
state;
|
|
392
485
|
workspaceProbeLogged = new Set();
|
|
486
|
+
/** Per-service immutable root-boundary verdicts; root detectors cache hits too. */
|
|
487
|
+
projectBoundaryCache = new Map();
|
|
393
488
|
warmStartLogged = new Set();
|
|
394
489
|
optionalFailureLogged = new Set();
|
|
490
|
+
/** Server/root pairs that already emitted unavailable for the current occurrence. */
|
|
491
|
+
unavailableLogged = new Set();
|
|
395
492
|
optionalDisabled = new Set();
|
|
396
493
|
/** Consecutive failure counts for exponential backoff circuit breaker */
|
|
397
494
|
failureCounts = new Map();
|
|
495
|
+
/**
|
|
496
|
+
* #1127: consecutive EARLY post-init runtime exits — a client that closed
|
|
497
|
+
* unexpectedly (not via our own `shutdown()`) with an uptime under
|
|
498
|
+
* {@link RUNTIME_EXIT_UPTIME_THRESHOLD_MS}. Tracked separately from
|
|
499
|
+
* {@link failureCounts} because a successful spawn/initialize clears that
|
|
500
|
+
* map (correct for the spawn/init failure class it was built for — see
|
|
501
|
+
* `spawnClient`'s success path) but is exactly the event a crash-loop
|
|
502
|
+
* server keeps producing right before it dies again; reusing the same map
|
|
503
|
+
* would make every respawn erase the streak the moment it re-spawned,
|
|
504
|
+
* which is the #1127 bug. This counter shares the SAME cooldown formula,
|
|
505
|
+
* the SAME `state.broken`/`permanentlyBroken` maps, and the SAME
|
|
506
|
+
* give-up-after-N-failures threshold as the spawn/init breaker — it is a
|
|
507
|
+
* parallel counter feeding one circuit, not a second mechanism.
|
|
508
|
+
*/
|
|
509
|
+
runtimeExitCounts = new Map();
|
|
510
|
+
/**
|
|
511
|
+
* #1142: rolling window of recent non-intentional death timestamps
|
|
512
|
+
* (`exitedAt`) per "serverId:root" key (the SAME identity as
|
|
513
|
+
* {@link runtimeExitCounts} and {@link LSPState.broken} — defect shape 1) —
|
|
514
|
+
* the SECOND, independent breaker trip condition that COMPOSES with the
|
|
515
|
+
* {@link runtimeExitCounts} fast path. That counter only sees CONSECUTIVE
|
|
516
|
+
* exits under the 60s threshold; a server that dies just PAST it every spawn
|
|
517
|
+
* resets that streak forever (#1142). This window trips when
|
|
518
|
+
* {@link RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths fall within
|
|
519
|
+
* {@link RUNTIME_EXIT_WINDOW_MS}, regardless of each death's lifetime.
|
|
520
|
+
*
|
|
521
|
+
* Bounded on BOTH axes (defect shape 9): entries older than the window are
|
|
522
|
+
* pruned on every record, AND the array is hard-capped at the trip count
|
|
523
|
+
* (drop-oldest) — a rate trip needs only the count within the window, never
|
|
524
|
+
* an unbounded history of timestamps. No timer backs it (defect shape 4): it
|
|
525
|
+
* ages purely by prune-on-check, so there is nothing to `unref()` or
|
|
526
|
+
* print-mode-gate. Like {@link runtimeExitCounts} it is a per-instance field,
|
|
527
|
+
* so a fresh `LSPService` (via `resetLSPService`) starts empty.
|
|
528
|
+
*/
|
|
529
|
+
runtimeExitWindow = new Map();
|
|
398
530
|
/** Server/root keys disabled for the rest of this session after repeated failures. */
|
|
399
531
|
permanentlyBroken = new Set();
|
|
400
532
|
/**
|
|
@@ -413,7 +545,26 @@ export class LSPService {
|
|
|
413
545
|
* those falls through to a fresh check rather than serving a stale result.
|
|
414
546
|
*/
|
|
415
547
|
lastKnownContentHash = new Map();
|
|
548
|
+
/**
|
|
549
|
+
* #1095: lazily verifies a stored {@link DiagnosticBinding} against current
|
|
550
|
+
* disk bytes, memoizing the disk fingerprint per (file, mtime) so repeated
|
|
551
|
+
* binding reads across `touchFile`/`getAllDiagnostics` within a session don't
|
|
552
|
+
* re-hash unchanged files. Owned by the service so the memo is shared.
|
|
553
|
+
*/
|
|
554
|
+
diskBindingCache = createDiskBindingCache();
|
|
416
555
|
lastDiagnosticsHealth = new Map();
|
|
556
|
+
/**
|
|
557
|
+
* Touch-debounce record of "this server already has this content", keyed
|
|
558
|
+
* "normalizedFilePath:clientScope:serverId".
|
|
559
|
+
*
|
|
560
|
+
* #743/#1253: the serverId component is load-bearing. #1253 requires that a
|
|
561
|
+
* server whose notify write never landed does NOT get an entry (otherwise the
|
|
562
|
+
* next touch debounces the re-push and the silent-clean gates read its silence
|
|
563
|
+
* as a confirmed clean). #743 requires that one wedged server must not degrade
|
|
564
|
+
* its healthy siblings. A file-level key can only satisfy one of those at a
|
|
565
|
+
* time — per-server, both hold: the stalled server simply has no entry and is
|
|
566
|
+
* re-pushed, while every sibling whose write landed keeps its own debounce.
|
|
567
|
+
*/
|
|
417
568
|
recentTouches = new Map();
|
|
418
569
|
/**
|
|
419
570
|
* #743: consecutive per-server notify-write timeout count, keyed by
|
|
@@ -424,6 +575,23 @@ export class LSPService {
|
|
|
424
575
|
* successful write clears its entry.
|
|
425
576
|
*/
|
|
426
577
|
notifyWriteBackpressureStreak = new Map();
|
|
578
|
+
/** LRU clock for capacity eviction, keyed by the canonical server/root key. */
|
|
579
|
+
clientLastUsedAt = new Map();
|
|
580
|
+
/**
|
|
581
|
+
* Manager-owned use leases close the acquisition/use gap that `isBusy()`
|
|
582
|
+
* cannot see: a caller may hold a selected client before its first request has
|
|
583
|
+
* entered the client. Eviction skips any key with an outstanding lease.
|
|
584
|
+
*/
|
|
585
|
+
clientLeases = new Map();
|
|
586
|
+
/**
|
|
587
|
+
* Per-root idle eviction for TypeScript's large, rebuildable program graph.
|
|
588
|
+
* Timers are unref'd so an idle language service cannot keep a one-shot host
|
|
589
|
+
* alive, and are removed before shutdown so a concurrent request rebuilds
|
|
590
|
+
* instead of receiving the retiring client.
|
|
591
|
+
*/
|
|
592
|
+
typeScriptIdleTimers = new Map();
|
|
593
|
+
/** Serializes capacity decisions with publication into `state.inFlight`. */
|
|
594
|
+
clientSpawnGate = Promise.resolve();
|
|
427
595
|
/** True after shutdown() has been called; blocks new operations */
|
|
428
596
|
isDestroyed = false;
|
|
429
597
|
/**
|
|
@@ -446,10 +614,215 @@ export class LSPService {
|
|
|
446
614
|
demonstratedCold: new Set(),
|
|
447
615
|
};
|
|
448
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Resolve one server's client identity root. Root selection is hard-bounded
|
|
619
|
+
* by the session cwd, then config-only nested roots reuse an already-hosted
|
|
620
|
+
* same-server ancestor. Real nested projects retain independent clients.
|
|
621
|
+
*/
|
|
622
|
+
async resolveServerRoot(server, filePath) {
|
|
623
|
+
const candidate = await server.root(filePath);
|
|
624
|
+
if (!candidate)
|
|
625
|
+
return undefined;
|
|
626
|
+
const root = enforceLspRootCeiling(candidate, process.cwd(), filePath);
|
|
627
|
+
if (normalizeMapKey(root) === normalizeMapKey(process.cwd()))
|
|
628
|
+
return root;
|
|
629
|
+
const rootKey = normalizeMapKey(root);
|
|
630
|
+
const prefix = `${server.id}:`;
|
|
631
|
+
let nearestAncestor;
|
|
632
|
+
for (const key of new Set([
|
|
633
|
+
...this.state.clients.keys(),
|
|
634
|
+
...this.state.inFlight.keys(),
|
|
635
|
+
])) {
|
|
636
|
+
if (!key.startsWith(prefix))
|
|
637
|
+
continue;
|
|
638
|
+
const ancestorKey = key.slice(prefix.length);
|
|
639
|
+
const relative = path.relative(ancestorKey, rootKey);
|
|
640
|
+
if (relative === "" ||
|
|
641
|
+
relative.startsWith("..") ||
|
|
642
|
+
path.isAbsolute(relative)) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (!nearestAncestor || ancestorKey.length > nearestAncestor.length) {
|
|
646
|
+
nearestAncestor = ancestorKey;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (!nearestAncestor)
|
|
650
|
+
return root;
|
|
651
|
+
let boundary = this.projectBoundaryCache.get(rootKey);
|
|
652
|
+
if (!boundary) {
|
|
653
|
+
boundary = hasProjectBoundaryMarker(root);
|
|
654
|
+
this.projectBoundaryCache.set(rootKey, boundary);
|
|
655
|
+
}
|
|
656
|
+
if (await boundary)
|
|
657
|
+
return root;
|
|
658
|
+
return (this.state.clients.get(`${server.id}:${nearestAncestor}`)?.root ??
|
|
659
|
+
nearestAncestor);
|
|
660
|
+
}
|
|
449
661
|
/** Guard: return true if service is shutting down or shut down */
|
|
450
662
|
checkDestroyed() {
|
|
451
663
|
return this.isDestroyed;
|
|
452
664
|
}
|
|
665
|
+
async withClientSpawnGate(operation) {
|
|
666
|
+
const previous = this.clientSpawnGate;
|
|
667
|
+
let release;
|
|
668
|
+
this.clientSpawnGate = new Promise((resolve) => {
|
|
669
|
+
release = resolve;
|
|
670
|
+
});
|
|
671
|
+
await previous;
|
|
672
|
+
try {
|
|
673
|
+
return await operation();
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
release();
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async clientKeyFor(entry, filePath) {
|
|
680
|
+
const root = await this.resolveServerRoot(entry.info, filePath);
|
|
681
|
+
return root ? `${entry.info.id}:${normalizeMapKey(root)}` : undefined;
|
|
682
|
+
}
|
|
683
|
+
async acquireClientLease(entry, filePath) {
|
|
684
|
+
const key = await this.clientKeyFor(entry, filePath);
|
|
685
|
+
if (!key)
|
|
686
|
+
return undefined;
|
|
687
|
+
return this.withClientSpawnGate(async () => {
|
|
688
|
+
if (this.isDestroyed || this.state.clients.get(key) !== entry.client) {
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
this.clientLeases.set(key, (this.clientLeases.get(key) ?? 0) + 1);
|
|
692
|
+
return key;
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
async acquireClientLeases(entries, filePath) {
|
|
696
|
+
const keys = await Promise.all(entries.map((entry) => this.clientKeyFor(entry, filePath)));
|
|
697
|
+
if (keys.some((key) => key === undefined))
|
|
698
|
+
return undefined;
|
|
699
|
+
const keyed = entries.map((entry, index) => [keys[index], entry]);
|
|
700
|
+
return this.withClientSpawnGate(async () => {
|
|
701
|
+
if (this.isDestroyed ||
|
|
702
|
+
keyed.some(([key, entry]) => this.state.clients.get(key) !== entry.client)) {
|
|
703
|
+
return undefined;
|
|
704
|
+
}
|
|
705
|
+
for (const [key] of keyed) {
|
|
706
|
+
this.clientLeases.set(key, (this.clientLeases.get(key) ?? 0) + 1);
|
|
707
|
+
}
|
|
708
|
+
return keyed.map(([key]) => key);
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
releaseClientLease(key) {
|
|
712
|
+
const remaining = (this.clientLeases.get(key) ?? 1) - 1;
|
|
713
|
+
if (remaining > 0) {
|
|
714
|
+
this.clientLeases.set(key, remaining);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
this.clientLeases.delete(key);
|
|
718
|
+
if (this.state.clients.has(key)) {
|
|
719
|
+
this.clientLastUsedAt.set(key, Date.now());
|
|
720
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async withClientForFileUse(filePath, maxWaitMs, hardCapMs, use) {
|
|
724
|
+
while (!this.isDestroyed) {
|
|
725
|
+
const entry = await this.getClientForFile(filePath, maxWaitMs, hardCapMs);
|
|
726
|
+
if (!entry)
|
|
727
|
+
return undefined;
|
|
728
|
+
const leaseKey = await this.acquireClientLease(entry, filePath);
|
|
729
|
+
if (!leaseKey)
|
|
730
|
+
continue;
|
|
731
|
+
try {
|
|
732
|
+
return await use(entry);
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
this.releaseClientLease(leaseKey);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
async makeCapacityForClient(key) {
|
|
741
|
+
const occupiedKeys = new Set(this.state.inFlight.keys());
|
|
742
|
+
for (const [clientKey, client] of this.state.clients) {
|
|
743
|
+
if (client.isAlive())
|
|
744
|
+
occupiedKeys.add(clientKey);
|
|
745
|
+
}
|
|
746
|
+
const occupied = occupiedKeys.size;
|
|
747
|
+
if (occupied < getLspClientCeiling())
|
|
748
|
+
return true;
|
|
749
|
+
const idle = [...this.state.clients.entries()]
|
|
750
|
+
.filter(([candidateKey, client]) => candidateKey !== key &&
|
|
751
|
+
client.isAlive() &&
|
|
752
|
+
client.isBusy?.() !== true &&
|
|
753
|
+
(this.clientLeases.get(candidateKey) ?? 0) === 0)
|
|
754
|
+
.sort(([a], [b]) => (this.clientLastUsedAt.get(a) ?? this.state.clientSpawnedAt.get(a) ?? 0) -
|
|
755
|
+
(this.clientLastUsedAt.get(b) ?? this.state.clientSpawnedAt.get(b) ?? 0));
|
|
756
|
+
const victim = idle[0];
|
|
757
|
+
if (!victim) {
|
|
758
|
+
logSessionStart(`lsp client ceiling ${getLspClientCeiling()}: spawn declined; all clients are in use`);
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
const [victimKey, victimClient] = victim;
|
|
762
|
+
await victimClient.shutdown({ reason: "client_ceiling_lru" });
|
|
763
|
+
this.state.clients.delete(victimKey);
|
|
764
|
+
this.state.clientSpawnedAt.delete(victimKey);
|
|
765
|
+
this.state.demonstratedReady.delete(victimKey);
|
|
766
|
+
this.state.demonstratedCold.delete(victimKey);
|
|
767
|
+
this.clientLastUsedAt.delete(victimKey);
|
|
768
|
+
this.clearTypeScriptIdleTimer(victimKey);
|
|
769
|
+
logSessionStart(`lsp client ceiling ${getLspClientCeiling()}: evicted idle LRU ${victimKey}`);
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
772
|
+
clearTypeScriptIdleTimer(key) {
|
|
773
|
+
const timer = this.typeScriptIdleTimers.get(key);
|
|
774
|
+
if (timer)
|
|
775
|
+
clearTimeout(timer);
|
|
776
|
+
this.typeScriptIdleTimers.delete(key);
|
|
777
|
+
}
|
|
778
|
+
scheduleTypeScriptIdleEviction(key) {
|
|
779
|
+
if (!key.startsWith("typescript:"))
|
|
780
|
+
return;
|
|
781
|
+
// Pressure-gating these timers would require a separate reconciliation pass
|
|
782
|
+
// when the manager crosses the threshold; keep ownership simple and use the
|
|
783
|
+
// warm-LSP-friendly 20-minute default instead.
|
|
784
|
+
this.clearTypeScriptIdleTimer(key);
|
|
785
|
+
const lastUsedAt = this.clientLastUsedAt.get(key) ?? Date.now();
|
|
786
|
+
const timer = setTimeout(() => {
|
|
787
|
+
this.typeScriptIdleTimers.delete(key);
|
|
788
|
+
void this.withClientSpawnGate(async () => {
|
|
789
|
+
if (this.isDestroyed)
|
|
790
|
+
return;
|
|
791
|
+
const client = this.state.clients.get(key);
|
|
792
|
+
if (!client?.isAlive())
|
|
793
|
+
return;
|
|
794
|
+
if (client.isBusy?.() === true ||
|
|
795
|
+
(this.clientLeases.get(key) ?? 0) > 0 ||
|
|
796
|
+
(this.clientLastUsedAt.get(key) ?? 0) !== lastUsedAt) {
|
|
797
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
// Publish the cold state synchronously before awaiting teardown. A request
|
|
801
|
+
// arriving while shutdown is in progress therefore waits on the spawn gate
|
|
802
|
+
// and creates a fresh client; it can never receive this retiring one.
|
|
803
|
+
this.state.clients.delete(key);
|
|
804
|
+
this.state.clientSpawnedAt.delete(key);
|
|
805
|
+
this.state.demonstratedReady.delete(key);
|
|
806
|
+
this.state.demonstratedCold.delete(key);
|
|
807
|
+
this.clientLastUsedAt.delete(key);
|
|
808
|
+
try {
|
|
809
|
+
await client.shutdown({ reason: "typescript_idle_eviction" });
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
// The strong manager reference is already gone; shutdown is best-effort
|
|
813
|
+
// like the other eviction paths and must not reject from a timer callback.
|
|
814
|
+
}
|
|
815
|
+
logSessionStart(`lsp typescript idle eviction: released ${key}`);
|
|
816
|
+
recordDegradation({
|
|
817
|
+
kind: "ts-idle-eviction",
|
|
818
|
+
subject: key,
|
|
819
|
+
reason: "idle TypeScript client released to bound memory",
|
|
820
|
+
});
|
|
821
|
+
}).catch(() => { });
|
|
822
|
+
}, getTypeScriptIdleEvictMs());
|
|
823
|
+
timer.unref?.();
|
|
824
|
+
this.typeScriptIdleTimers.set(key, timer);
|
|
825
|
+
}
|
|
453
826
|
fingerprintContent(content) {
|
|
454
827
|
if (content.length <= 96) {
|
|
455
828
|
return `${content.length}:${content}`;
|
|
@@ -461,10 +834,15 @@ export class LSPService {
|
|
|
461
834
|
* NOT need diagnostics — those callers still need to wait for the LSP to
|
|
462
835
|
* publish, even if the notify itself is a no-op.
|
|
463
836
|
*/
|
|
464
|
-
shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics) {
|
|
837
|
+
shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics, serverIds) {
|
|
465
838
|
if (waitForDiagnostics)
|
|
466
839
|
return false;
|
|
467
|
-
|
|
840
|
+
// #743: only short-circuit the whole call when EVERY spawned server already
|
|
841
|
+
// has this content. If even one still needs the push, fall through — the
|
|
842
|
+
// write loop skips the servers that are covered and pushes only the rest.
|
|
843
|
+
if (serverIds.length === 0)
|
|
844
|
+
return false;
|
|
845
|
+
return serverIds.every((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId));
|
|
468
846
|
}
|
|
469
847
|
/**
|
|
470
848
|
* Should the didOpen/didChange notify be skipped while keeping the
|
|
@@ -478,12 +856,14 @@ export class LSPService {
|
|
|
478
856
|
* with diagnosticsMode="document" moments later. Without this check the
|
|
479
857
|
* second call's notify clears in-progress diagnostics and the LSP has to
|
|
480
858
|
* start over — observed as multi-second waits on slow TS projects.
|
|
859
|
+
*
|
|
860
|
+
* Decided PER SERVER (#743): a sibling's stalled write must not cost this
|
|
861
|
+
* server its debounce. See {@link recentTouches}.
|
|
481
862
|
*/
|
|
482
|
-
shouldSkipNotify(filePath, content, clientScope) {
|
|
863
|
+
shouldSkipNotify(filePath, content, clientScope, serverId) {
|
|
483
864
|
if (TOUCH_DEBOUNCE_MS <= 0)
|
|
484
865
|
return false;
|
|
485
|
-
const
|
|
486
|
-
const previous = this.recentTouches.get(key);
|
|
866
|
+
const previous = this.recentTouches.get(this.recentTouchKey(filePath, clientScope, serverId));
|
|
487
867
|
if (!previous)
|
|
488
868
|
return false;
|
|
489
869
|
const now = Date.now();
|
|
@@ -491,8 +871,11 @@ export class LSPService {
|
|
|
491
871
|
return false;
|
|
492
872
|
return previous.fingerprint === this.fingerprintContent(content);
|
|
493
873
|
}
|
|
494
|
-
|
|
495
|
-
|
|
874
|
+
recentTouchKey(filePath, clientScope, serverId) {
|
|
875
|
+
return `${normalizeMapKey(filePath)}:${clientScope}:${serverId}`;
|
|
876
|
+
}
|
|
877
|
+
markTouched(filePath, content, clientScope, serverId) {
|
|
878
|
+
const key = this.recentTouchKey(filePath, clientScope, serverId);
|
|
496
879
|
const now = Date.now();
|
|
497
880
|
this.recentTouches.set(key, {
|
|
498
881
|
fingerprint: this.fingerprintContent(content),
|
|
@@ -501,8 +884,10 @@ export class LSPService {
|
|
|
501
884
|
});
|
|
502
885
|
// Trim entries that are already past the debounce window — shouldSkipTouch
|
|
503
886
|
// ignores them anyway, so they serve no purpose. Only sweep when the map
|
|
504
|
-
// exceeds the threshold to avoid iterating on every call.
|
|
505
|
-
|
|
887
|
+
// exceeds the threshold to avoid iterating on every call. The threshold is
|
|
888
|
+
// per-ENTRY and entries are now per-server, so it is scaled up to keep the
|
|
889
|
+
// same effective file capacity a file-level key gave a multi-server scope.
|
|
890
|
+
if (this.recentTouches.size > 800) {
|
|
506
891
|
for (const [k, v] of this.recentTouches) {
|
|
507
892
|
if (now - v.touchedAt > TOUCH_DEBOUNCE_MS) {
|
|
508
893
|
this.recentTouches.delete(k);
|
|
@@ -522,7 +907,7 @@ export class LSPService {
|
|
|
522
907
|
* implementation that doesn't independently stamp `.root`).
|
|
523
908
|
*/
|
|
524
909
|
async demonstratedReadyKeyFor(server, filePath) {
|
|
525
|
-
const root = await
|
|
910
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
526
911
|
if (!root)
|
|
527
912
|
return undefined;
|
|
528
913
|
return `${server.id}:${normalizeMapKey(root)}`;
|
|
@@ -534,6 +919,9 @@ export class LSPService {
|
|
|
534
919
|
// the negative cache.
|
|
535
920
|
this.state.demonstratedCold.delete(key);
|
|
536
921
|
}
|
|
922
|
+
recordBreaker(key, reason) {
|
|
923
|
+
recordDegradationOnce({ kind: "lsp-breaker", subject: key, reason });
|
|
924
|
+
}
|
|
537
925
|
/**
|
|
538
926
|
* #743: record one notify-write timeout for a server and, once it has stalled
|
|
539
927
|
* {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row, demote it through
|
|
@@ -558,6 +946,8 @@ export class LSPService {
|
|
|
558
946
|
this.state.clients.delete(key);
|
|
559
947
|
this.state.clientSpawnedAt.delete(key);
|
|
560
948
|
this.state.demonstratedReady.delete(key);
|
|
949
|
+
this.clientLastUsedAt.delete(key);
|
|
950
|
+
this.clearTypeScriptIdleTimer(key);
|
|
561
951
|
logLatency({
|
|
562
952
|
type: "phase",
|
|
563
953
|
phase: "lsp_notify_backpressure_broken",
|
|
@@ -633,14 +1023,25 @@ export class LSPService {
|
|
|
633
1023
|
return spawned;
|
|
634
1024
|
}
|
|
635
1025
|
}
|
|
1026
|
+
const unavailable = (await Promise.all(servers.map(async (server) => {
|
|
1027
|
+
const root = await server.root(filePath);
|
|
1028
|
+
return {
|
|
1029
|
+
server,
|
|
1030
|
+
key: `${server.id}:${root ? normalizeMapKey(root) : "<unresolved>"}`,
|
|
1031
|
+
};
|
|
1032
|
+
}))).filter(({ key }) => !this.unavailableLogged.has(key));
|
|
1033
|
+
for (const { key } of unavailable)
|
|
1034
|
+
this.unavailableLogged.add(key);
|
|
1035
|
+
if (unavailable.length === 0)
|
|
1036
|
+
return undefined;
|
|
636
1037
|
logLatency({
|
|
637
1038
|
type: "phase",
|
|
638
1039
|
phase: "lsp_client_unavailable",
|
|
639
1040
|
filePath,
|
|
640
1041
|
durationMs: 0,
|
|
641
1042
|
metadata: {
|
|
642
|
-
candidateCount:
|
|
643
|
-
servers:
|
|
1043
|
+
candidateCount: unavailable.length,
|
|
1044
|
+
servers: unavailable.map(({ server }) => server.id),
|
|
644
1045
|
},
|
|
645
1046
|
});
|
|
646
1047
|
return undefined;
|
|
@@ -649,10 +1050,31 @@ export class LSPService {
|
|
|
649
1050
|
return withBudget();
|
|
650
1051
|
}
|
|
651
1052
|
const timeoutSentinel = Symbol("lsp-client-wait-timeout");
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
1053
|
+
// #1097: store the timer and clear it once the race settles. Without this,
|
|
1054
|
+
// when `withBudget()` wins (the common case: the client is ready well before
|
|
1055
|
+
// the budget), the losing `setTimeout` stays a REF'D pending timer for the
|
|
1056
|
+
// full remaining `effectiveMaxWaitMs`. In a long-lived interactive session
|
|
1057
|
+
// that is invisible (it fires later, resolves an orphan promise, is GC'd).
|
|
1058
|
+
// In a one-shot `pi --print --no-session` process it is fatal: the timer
|
|
1059
|
+
// keeps the event loop alive for up to `effectiveMaxWaitMs` after
|
|
1060
|
+
// `agent_settled`/`session_shutdown`, so the completed process never exits
|
|
1061
|
+
// (issue #1097 — a recurrence of the #22 symptom via a different handle, and
|
|
1062
|
+
// a member of the uncleared-race-timeout class the shared `withDeadline`
|
|
1063
|
+
// helper already guards against elsewhere).
|
|
1064
|
+
let waitTimer;
|
|
1065
|
+
let waitResult;
|
|
1066
|
+
try {
|
|
1067
|
+
waitResult = await Promise.race([
|
|
1068
|
+
withBudget(),
|
|
1069
|
+
new Promise((resolve) => {
|
|
1070
|
+
waitTimer = setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs);
|
|
1071
|
+
}),
|
|
1072
|
+
]);
|
|
1073
|
+
}
|
|
1074
|
+
finally {
|
|
1075
|
+
if (waitTimer)
|
|
1076
|
+
clearTimeout(waitTimer);
|
|
1077
|
+
}
|
|
656
1078
|
if (waitResult === timeoutSentinel) {
|
|
657
1079
|
// Snapshot known client health — scan by serverId prefix (no root needed)
|
|
658
1080
|
const knownHealth = [...this.state.clients.entries()]
|
|
@@ -691,7 +1113,7 @@ export class LSPService {
|
|
|
691
1113
|
return { clients: [], serverCountAttempted: 0 };
|
|
692
1114
|
// Count servers with a valid root as "attempted" — extension-only matches
|
|
693
1115
|
// that fail the root check are not real spawn attempts.
|
|
694
|
-
const roots = await Promise.all(servers.map((s) =>
|
|
1116
|
+
const roots = await Promise.all(servers.map((s) => this.resolveServerRoot(s, filePath)));
|
|
695
1117
|
const serverCountAttempted = roots.filter(Boolean).length;
|
|
696
1118
|
const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
|
|
697
1119
|
return {
|
|
@@ -722,7 +1144,7 @@ export class LSPService {
|
|
|
722
1144
|
return undefined;
|
|
723
1145
|
const servers = getServersForFileWithConfig(filePath);
|
|
724
1146
|
for (const server of servers) {
|
|
725
|
-
const root = await
|
|
1147
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
726
1148
|
if (!root)
|
|
727
1149
|
continue;
|
|
728
1150
|
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
@@ -744,7 +1166,7 @@ export class LSPService {
|
|
|
744
1166
|
for (const server of getServersForFileWithConfig(filePath)) {
|
|
745
1167
|
if (server.id !== serverId)
|
|
746
1168
|
continue;
|
|
747
|
-
const root = await
|
|
1169
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
748
1170
|
if (!root)
|
|
749
1171
|
continue;
|
|
750
1172
|
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
@@ -763,10 +1185,10 @@ export class LSPService {
|
|
|
763
1185
|
if (this.checkDestroyed())
|
|
764
1186
|
return undefined;
|
|
765
1187
|
}
|
|
766
|
-
const root = await
|
|
1188
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
767
1189
|
if (!root || this.checkDestroyed())
|
|
768
1190
|
return undefined;
|
|
769
|
-
const allowInstall = this.shouldAllowInstall(
|
|
1191
|
+
const allowInstall = this.shouldAllowInstall(server.id);
|
|
770
1192
|
const normalizedRoot = normalizeMapKey(root);
|
|
771
1193
|
const key = `${server.id}:${normalizedRoot}`;
|
|
772
1194
|
const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
|
|
@@ -803,6 +1225,9 @@ export class LSPService {
|
|
|
803
1225
|
const existing = this.state.clients.get(key);
|
|
804
1226
|
if (existing) {
|
|
805
1227
|
if (existing.isAlive()) {
|
|
1228
|
+
this.unavailableLogged.delete(key);
|
|
1229
|
+
this.clientLastUsedAt.set(key, Date.now());
|
|
1230
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
806
1231
|
if (!this.warmStartLogged.has(key)) {
|
|
807
1232
|
logSessionStart(`lsp warm-start ${server.id}: reused root=${root} file=${filePath}`);
|
|
808
1233
|
this.warmStartLogged.add(key);
|
|
@@ -811,6 +1236,28 @@ export class LSPService {
|
|
|
811
1236
|
}
|
|
812
1237
|
// Dead client — was previously alive, now needs respawn
|
|
813
1238
|
const spawnedAt = this.state.clientSpawnedAt.get(key);
|
|
1239
|
+
// #1127: capture BEFORE calling existing.shutdown() below — both
|
|
1240
|
+
// wasShutdownIntentional() and getExitedAt() read state that
|
|
1241
|
+
// existing.shutdown() itself mutates as a side effect of our own
|
|
1242
|
+
// cleanup (shutdownRequested flips true, and the process exit it
|
|
1243
|
+
// triggers would stamp exitedAt at CLEANUP time rather than at the
|
|
1244
|
+
// real death), so both must be read before that call.
|
|
1245
|
+
const wasIntentional = existing.wasShutdownIntentional();
|
|
1246
|
+
const exitedAt = existing.getExitedAt();
|
|
1247
|
+
// #1127: lifetime MUST be measured from the client's own recorded
|
|
1248
|
+
// death (exitedAt), not from "now" (this detection). Detection is
|
|
1249
|
+
// lazy — the next file attach — and #1127's real-world pattern is
|
|
1250
|
+
// attach-triggered respawns minutes to HOURS apart: a server that
|
|
1251
|
+
// died 5s after spawning but wasn't detected until an hour later
|
|
1252
|
+
// must still read as an early, breaker-worthy exit. Fall back to the
|
|
1253
|
+
// detection-time delta only when exitedAt is somehow unset (the
|
|
1254
|
+
// client's own exit handlers always set it before isAlive() can go
|
|
1255
|
+
// false, so this is a defensive fallback, not the expected path).
|
|
1256
|
+
const uptimeMs = exitedAt != null && spawnedAt != null
|
|
1257
|
+
? exitedAt - spawnedAt
|
|
1258
|
+
: spawnedAt != null
|
|
1259
|
+
? Date.now() - spawnedAt
|
|
1260
|
+
: null;
|
|
814
1261
|
logLatency({
|
|
815
1262
|
type: "phase",
|
|
816
1263
|
phase: "lsp_server_respawn",
|
|
@@ -819,7 +1266,8 @@ export class LSPService {
|
|
|
819
1266
|
metadata: {
|
|
820
1267
|
serverId: server.id,
|
|
821
1268
|
root,
|
|
822
|
-
uptimeMs
|
|
1269
|
+
uptimeMs,
|
|
1270
|
+
intentional: wasIntentional,
|
|
823
1271
|
},
|
|
824
1272
|
});
|
|
825
1273
|
try {
|
|
@@ -830,7 +1278,127 @@ export class LSPService {
|
|
|
830
1278
|
}
|
|
831
1279
|
this.state.clients.delete(key);
|
|
832
1280
|
this.state.clientSpawnedAt.delete(key);
|
|
1281
|
+
this.clientLastUsedAt.delete(key);
|
|
1282
|
+
this.clearTypeScriptIdleTimer(key);
|
|
833
1283
|
this.state.broken.delete(key);
|
|
1284
|
+
// #1127: count EARLY, non-intentional runtime exits toward the circuit
|
|
1285
|
+
// breaker. Spawn/initialize itself succeeded here (this client made it
|
|
1286
|
+
// into state.clients), so `failureCounts` — the spawn/init breaker —
|
|
1287
|
+
// never saw this failure and was cleared to 0 on that earlier success.
|
|
1288
|
+
// Without this, a server that spawns fine and then dies shortly after
|
|
1289
|
+
// serving (opengrep's post-init "Unhandled message" crash, #1122/#1127)
|
|
1290
|
+
// respawns forever: cooldown/permanent-disable never engage.
|
|
1291
|
+
//
|
|
1292
|
+
// Deliberate teardowns (session reset, #743 notify-backpressure
|
|
1293
|
+
// eviction, a generation handoff) call `shutdown()` themselves and set
|
|
1294
|
+
// `shutdownRequested` before the process exits — those must never count,
|
|
1295
|
+
// or a legitimate restart would wrongly march the server toward
|
|
1296
|
+
// permanent disablement.
|
|
1297
|
+
if (wasIntentional) {
|
|
1298
|
+
// Not evidence of a bad server — leave any existing runtime-exit
|
|
1299
|
+
// streak alone (a deliberate restart is not a recovery signal
|
|
1300
|
+
// either; only clear the streak once the server proves itself by
|
|
1301
|
+
// staying up past the threshold, handled in the `else` below). The
|
|
1302
|
+
// windowed-rate trip below is likewise gated behind this
|
|
1303
|
+
// `!wasIntentional` guard, so user-initiated restarts, config/
|
|
1304
|
+
// workspace reloads, session changes, #743 eviction and generation
|
|
1305
|
+
// handoffs (all shutdown()-driven) never feed it.
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
// #1142: windowed-rate trip — the SECOND, independent breaker
|
|
1309
|
+
// condition, composed with the fast path below. Record this
|
|
1310
|
+
// non-intentional death and, if RUNTIME_EXIT_WINDOW_TRIP_COUNT
|
|
1311
|
+
// deaths fell within the rolling window, give up NOW regardless of
|
|
1312
|
+
// each death's individual lifetime. This catches the slow loop
|
|
1313
|
+
// (dies just past the 60s threshold every spawn) that the
|
|
1314
|
+
// consecutive-early-exit fast path structurally cannot see.
|
|
1315
|
+
if (this.recordRuntimeExitWindow(key, exitedAt, uptimeMs)) {
|
|
1316
|
+
const rate = `${RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths within ${Math.round(RUNTIME_EXIT_WINDOW_MS / 60_000)}m`;
|
|
1317
|
+
// Set a cooldown so THIS detection also stops respawning (the
|
|
1318
|
+
// broken check below at the end of this method re-reads it),
|
|
1319
|
+
// symmetric with the fast path which cools down on every count.
|
|
1320
|
+
// `max` is defensive, not load-bearing under the actual call
|
|
1321
|
+
// order: `state.broken` for this key is `delete`d at the top of
|
|
1322
|
+
// this method (see the "detect dead client" branch above), so
|
|
1323
|
+
// this `.get(key) ?? 0` always reads 0 here — it's the fast
|
|
1324
|
+
// path below that runs AFTER this block and can raise the
|
|
1325
|
+
// cooldown for the same death. Harmless either way: the
|
|
1326
|
+
// non-optional branch latches `permanentlyBroken` (making the
|
|
1327
|
+
// cooldown moot), and the optional branch here is currently
|
|
1328
|
+
// unreachable (`OPTIONAL_LSP_SERVER_IDS` is empty).
|
|
1329
|
+
this.state.broken.set(key, Math.max(this.state.broken.get(key) ?? 0, Date.now() +
|
|
1330
|
+
(isOptionalServer
|
|
1331
|
+
? OPTIONAL_LSP_RETRY_COOLDOWN_MS
|
|
1332
|
+
: BROKEN_MAX_COOLDOWN_MS)));
|
|
1333
|
+
if (isOptionalServer) {
|
|
1334
|
+
// Optional servers never latch permanently (mirrors the fast
|
|
1335
|
+
// path): cool down + disable for a retry interval, and reset
|
|
1336
|
+
// the window so the trip must be re-earned afterward.
|
|
1337
|
+
this.optionalDisabled.add(key);
|
|
1338
|
+
this.runtimeExitWindow.delete(key);
|
|
1339
|
+
logSessionStart(`lsp respawn ${server.id}: optional server cooled down (windowed-rate trip: ${rate})`);
|
|
1340
|
+
}
|
|
1341
|
+
else {
|
|
1342
|
+
this.permanentlyBroken.add(key);
|
|
1343
|
+
this.recordBreaker(key, `windowed runtime-exit trip: ${rate}`);
|
|
1344
|
+
logSessionStart(`lsp respawn ${server.id}: permanently disabled (windowed-rate trip: ${rate}, uptimeMs=${uptimeMs})`);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
// #1127/#1139 fast path — CONSECUTIVE exits UNDER the 60s
|
|
1348
|
+
// threshold. UNCHANGED: its own counter, its own trip point. A
|
|
1349
|
+
// single death may feed BOTH accumulators (the window above AND
|
|
1350
|
+
// runtimeExitCounts here), but neither alters the other's threshold,
|
|
1351
|
+
// so the fast path's trip point is exactly what it was.
|
|
1352
|
+
if (uptimeMs != null &&
|
|
1353
|
+
uptimeMs < RUNTIME_EXIT_UPTIME_THRESHOLD_MS) {
|
|
1354
|
+
const rCount = (this.runtimeExitCounts.get(key) ?? 0) + 1;
|
|
1355
|
+
this.runtimeExitCounts.set(key, rCount);
|
|
1356
|
+
const rCooldown = Math.min(BROKEN_BASE_COOLDOWN_MS * 2 ** (rCount - 1), BROKEN_MAX_COOLDOWN_MS);
|
|
1357
|
+
this.state.broken.set(key, Date.now() + rCooldown);
|
|
1358
|
+
if (!isOptionalServer && rCount >= BROKEN_PERMANENT_AFTER) {
|
|
1359
|
+
this.permanentlyBroken.add(key);
|
|
1360
|
+
this.recordBreaker(key, `permanently disabled after ${rCount} early post-init exits`);
|
|
1361
|
+
logSessionStart(`lsp respawn ${server.id}: permanently disabled after ${rCount} early post-init exits (uptimeMs=${uptimeMs})`);
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
logSessionStart(`lsp respawn ${server.id}: early post-init exit ${rCount}/${BROKEN_PERMANENT_AFTER} (uptimeMs=${uptimeMs}), cooldown=${rCooldown}ms`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
else {
|
|
1368
|
+
// Survived past the threshold (or uptime unknown) before dying —
|
|
1369
|
+
// this was a functioning server, not a HOT crash loop. Reset the
|
|
1370
|
+
// consecutive-early streak so an occasional post-longrun crash
|
|
1371
|
+
// doesn't creep it toward permanent disablement. The windowed
|
|
1372
|
+
// counter above is deliberately NOT reset here: a ~65-90s death
|
|
1373
|
+
// lands in this branch yet IS the churn #1142 targets — the
|
|
1374
|
+
// window ages those out by time instead.
|
|
1375
|
+
this.runtimeExitCounts.delete(key);
|
|
1376
|
+
// Outer-map hygiene (#1183): runtimeExitWindow itself is only
|
|
1377
|
+
// ever `delete`d on the optional-trip branch above (currently
|
|
1378
|
+
// unreachable — OPTIONAL_LSP_SERVER_IDS is empty), so a key
|
|
1379
|
+
// that crashed once then recovered would otherwise retain a
|
|
1380
|
+
// stale timestamp array forever, even once every entry in it
|
|
1381
|
+
// has aged out of the window. Drop it here once nothing in it
|
|
1382
|
+
// is still in-window — purely map hygiene: a later death
|
|
1383
|
+
// rebuilds the array from scratch via recordRuntimeExitWindow,
|
|
1384
|
+
// so this cannot change trip behavior. Anchor "now" on this
|
|
1385
|
+
// death's own `exitedAt` (falling back to real time only if
|
|
1386
|
+
// unset), NOT `Date.now()` directly — this death's timestamp
|
|
1387
|
+
// is what `recordRuntimeExitWindow` itself anchors pruning on
|
|
1388
|
+
// (see its `windowRef` comment), so a death that WAS just
|
|
1389
|
+
// recorded is guaranteed to still be in-window here and this
|
|
1390
|
+
// can only ever fire for the "declined to record" cases
|
|
1391
|
+
// (over the sleep-gap ceiling, or a missing `exitedAt`) where
|
|
1392
|
+
// nothing new was added and the array is genuinely stale.
|
|
1393
|
+
const windowDeaths = this.runtimeExitWindow.get(key);
|
|
1394
|
+
if (windowDeaths) {
|
|
1395
|
+
const windowNow = exitedAt ?? Date.now();
|
|
1396
|
+
if (!windowDeaths.some((t) => t >= windowNow - RUNTIME_EXIT_WINDOW_MS)) {
|
|
1397
|
+
this.runtimeExitWindow.delete(key);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
834
1402
|
}
|
|
835
1403
|
const brokenUntil = this.state.broken.get(key);
|
|
836
1404
|
if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
|
|
@@ -851,31 +1419,53 @@ export class LSPService {
|
|
|
851
1419
|
if (isOptionalServer)
|
|
852
1420
|
this.optionalDisabled.delete(key);
|
|
853
1421
|
}
|
|
854
|
-
|
|
855
|
-
if (
|
|
856
|
-
|
|
1422
|
+
let spawnPromise = this.state.inFlight.get(key);
|
|
1423
|
+
if (!spawnPromise) {
|
|
1424
|
+
const started = await this.withClientSpawnGate(async () => {
|
|
1425
|
+
const raced = this.state.inFlight.get(key);
|
|
1426
|
+
if (raced)
|
|
1427
|
+
return { promise: raced };
|
|
1428
|
+
// `server.root()` and dead-client cleanup above are async. A reset during
|
|
1429
|
+
// either gap must not let this retired generation start a late spawn.
|
|
1430
|
+
if (this.checkDestroyed())
|
|
1431
|
+
return undefined;
|
|
1432
|
+
if (!(await this.makeCapacityForClient(key)))
|
|
1433
|
+
return undefined;
|
|
1434
|
+
const promise = this.spawnClient(server, root, key, filePath, allowInstall);
|
|
1435
|
+
this.state.inFlight.set(key, promise);
|
|
1436
|
+
return { promise };
|
|
1437
|
+
});
|
|
1438
|
+
if (!started)
|
|
1439
|
+
return undefined;
|
|
1440
|
+
spawnPromise = started.promise;
|
|
857
1441
|
}
|
|
858
|
-
// `server.root()` and a dead client's shutdown above are both async. A
|
|
859
|
-
// reset during either gap may have completed without seeing this request in
|
|
860
|
-
// state.inFlight; never let that retired generation start a late spawn.
|
|
861
|
-
if (this.checkDestroyed())
|
|
862
|
-
return undefined;
|
|
863
|
-
const spawnPromise = this.spawnClient(server, root, key, filePath, allowInstall);
|
|
864
|
-
this.state.inFlight.set(key, spawnPromise);
|
|
865
1442
|
try {
|
|
866
1443
|
return await spawnPromise;
|
|
867
1444
|
}
|
|
868
1445
|
finally {
|
|
869
|
-
this.state.inFlight.
|
|
1446
|
+
if (this.state.inFlight.get(key) === spawnPromise) {
|
|
1447
|
+
this.state.inFlight.delete(key);
|
|
1448
|
+
}
|
|
870
1449
|
}
|
|
871
1450
|
}
|
|
872
|
-
shouldAllowInstall(
|
|
1451
|
+
shouldAllowInstall(serverId) {
|
|
1452
|
+
if (!assertInstallAllowed(`lsp install: ${serverId}`))
|
|
1453
|
+
return false;
|
|
873
1454
|
return process.env.PI_LENS_DISABLE_LSP_INSTALL !== "1";
|
|
874
1455
|
}
|
|
875
1456
|
/**
|
|
876
1457
|
* Internal: spawn a client for a server/root combination
|
|
877
1458
|
*/
|
|
878
1459
|
async spawnClient(server, root, key, filePath, allowInstall) {
|
|
1460
|
+
// #1334 S5: honor the host project-trust decision before executing any
|
|
1461
|
+
// project-resolved binary. Only an explicit host "not trusted" blocks —
|
|
1462
|
+
// a host with no trust surface (`"unknown"`) spawns exactly as before.
|
|
1463
|
+
// Deliberately NOT marked broken: trust is a policy outcome, not a server
|
|
1464
|
+
// failure, and the user may grant trust later in the same session.
|
|
1465
|
+
if (!isLspSpawnAllowedByTrust()) {
|
|
1466
|
+
logSessionStart(`lsp spawn ${server.id}: refused — ${projectTrustDenialReason()}`);
|
|
1467
|
+
return undefined;
|
|
1468
|
+
}
|
|
879
1469
|
const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
|
|
880
1470
|
const startedAt = Date.now();
|
|
881
1471
|
logSessionStart(`lsp spawn ${server.id}: start root=${root} install=${allowInstall ? "enabled" : "disabled"} file=${filePath}`);
|
|
@@ -914,6 +1504,7 @@ export class LSPService {
|
|
|
914
1504
|
this.state.broken.set(key, Date.now() + uCooldown);
|
|
915
1505
|
if (uCount >= BROKEN_PERMANENT_AFTER) {
|
|
916
1506
|
this.permanentlyBroken.add(key);
|
|
1507
|
+
this.recordBreaker(key, `permanently disabled after ${uCount} unavailable spawns`);
|
|
917
1508
|
logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${uCount} failures`);
|
|
918
1509
|
}
|
|
919
1510
|
return undefined;
|
|
@@ -943,7 +1534,10 @@ export class LSPService {
|
|
|
943
1534
|
diagnosticProviderKind: "unavailable",
|
|
944
1535
|
};
|
|
945
1536
|
this.state.clients.set(key, client);
|
|
1537
|
+
this.unavailableLogged.delete(key);
|
|
946
1538
|
this.state.clientSpawnedAt.set(key, Date.now());
|
|
1539
|
+
this.clientLastUsedAt.set(key, Date.now());
|
|
1540
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
947
1541
|
this.failureCounts.delete(key);
|
|
948
1542
|
if (isOptionalServer) {
|
|
949
1543
|
this.optionalDisabled.delete(key);
|
|
@@ -973,6 +1567,7 @@ export class LSPService {
|
|
|
973
1567
|
this.state.broken.set(key, Date.now() + eCooldown);
|
|
974
1568
|
if (!isOptionalServer && eCount >= BROKEN_PERMANENT_AFTER) {
|
|
975
1569
|
this.permanentlyBroken.add(key);
|
|
1570
|
+
this.recordBreaker(key, `permanently disabled after ${eCount} spawn/initialize failures`);
|
|
976
1571
|
logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${eCount} failures`);
|
|
977
1572
|
}
|
|
978
1573
|
if (isOptionalServer) {
|
|
@@ -987,11 +1582,10 @@ export class LSPService {
|
|
|
987
1582
|
async openFile(filePath, content, options) {
|
|
988
1583
|
if (this.checkDestroyed())
|
|
989
1584
|
return;
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
|
|
1585
|
+
await this.withClientForFileUse(filePath, undefined, options?.spawnBudgetMs, async (spawned) => {
|
|
1586
|
+
const languageId = getLanguageId(filePath) ?? "plaintext";
|
|
1587
|
+
await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
|
|
1588
|
+
});
|
|
995
1589
|
}
|
|
996
1590
|
/**
|
|
997
1591
|
* Update file content (sends textDocument/didChange)
|
|
@@ -999,10 +1593,7 @@ export class LSPService {
|
|
|
999
1593
|
async updateFile(filePath, content) {
|
|
1000
1594
|
if (this.checkDestroyed())
|
|
1001
1595
|
return;
|
|
1002
|
-
|
|
1003
|
-
if (!spawned)
|
|
1004
|
-
return;
|
|
1005
|
-
await spawned.client.notify.change(filePath, content);
|
|
1596
|
+
await this.withClientForFileUse(filePath, undefined, undefined, (spawned) => spawned.client.notify.change(filePath, content));
|
|
1006
1597
|
}
|
|
1007
1598
|
/**
|
|
1008
1599
|
* Touch a file like OpenCode's LSP flow: ensure document is open/synced,
|
|
@@ -1064,533 +1655,733 @@ export class LSPService {
|
|
|
1064
1655
|
});
|
|
1065
1656
|
return;
|
|
1066
1657
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
durationMs: Date.now() - startedAt,
|
|
1073
|
-
metadata: {
|
|
1074
|
-
serverCountReady: spawned.length,
|
|
1075
|
-
clientScope,
|
|
1076
|
-
diagnosticsMode,
|
|
1077
|
-
source,
|
|
1078
|
-
failureKind: "success",
|
|
1079
|
-
skipped: true,
|
|
1080
|
-
reason: "debounced_unchanged_content",
|
|
1081
|
-
},
|
|
1082
|
-
});
|
|
1083
|
-
return [];
|
|
1658
|
+
const leaseKeys = await this.acquireClientLeases(spawned, filePath);
|
|
1659
|
+
if (!leaseKeys) {
|
|
1660
|
+
// An idle eviction won between selection and lease admission. Resolve the
|
|
1661
|
+
// now-current client set and replay; no notification targets the retiree.
|
|
1662
|
+
return this.touchFile(filePath, content, options);
|
|
1084
1663
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
// debounce window, skip the notify — pushing again clears the LSP's
|
|
1089
|
-
// diagnostic cache (via notify.open) and forces it to restart work it
|
|
1090
|
-
// already did. This is what makes the post-write touch + dispatch-lsp-
|
|
1091
|
-
// runner touch sequence expensive on slow TS projects.
|
|
1092
|
-
const notifySkipped = this.shouldSkipNotify(filePath, content, clientScope);
|
|
1093
|
-
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
|
|
1094
|
-
// #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
|
|
1095
|
-
// write gets its OWN notifyWriteBudgetMs budget rather than one shared
|
|
1096
|
-
// deadline over a single Promise.all — otherwise one backpressured server
|
|
1097
|
-
// (stalled stdin) times out the write for the ENTIRE file, flipping every
|
|
1098
|
-
// co-touched healthy server to inconclusive and zeroing its diagnostics.
|
|
1099
|
-
// Bounded so a backpressured write can't hang the caller; on timeout we
|
|
1100
|
-
// proceed — the diagnostics wait below is separately bounded and simply
|
|
1101
|
-
// returns no fresh diagnostics for the server(s) that stalled.
|
|
1102
|
-
//
|
|
1103
|
-
// Holds the serverId of every server whose write did NOT land in time.
|
|
1104
|
-
// The file-level `notifyWriteTimedOut` (logged below) means "at least one
|
|
1105
|
-
// server timed out"; this list carries the per-server detail the
|
|
1106
|
-
// demonstratedReady gate reads so a healthy sibling stays eligible.
|
|
1107
|
-
const notifyWriteTimedOutServerIds = [];
|
|
1108
|
-
if (!notifySkipped) {
|
|
1109
|
-
const budget = notifyWriteBudgetMs();
|
|
1110
|
-
await Promise.all(spawned.map(async (entry) => {
|
|
1111
|
-
// Same identity as the broken/demonstratedReady maps.
|
|
1112
|
-
const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1113
|
-
let wrote;
|
|
1114
|
-
let rejected = false;
|
|
1115
|
-
try {
|
|
1116
|
-
wrote = await withDeadline(entry.client.notify
|
|
1117
|
-
.open(filePath, content, languageId, undefined, silent)
|
|
1118
|
-
.then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
|
|
1119
|
-
}
|
|
1120
|
-
catch {
|
|
1121
|
-
// The write itself rejected (not backpressure): the content did
|
|
1122
|
-
// not land, so this server is inconclusive for the touch, but a
|
|
1123
|
-
// rejection is not a stdin-backpressure signal and must not count
|
|
1124
|
-
// toward the backpressure demotion streak.
|
|
1125
|
-
rejected = true;
|
|
1126
|
-
}
|
|
1127
|
-
if (wrote === true) {
|
|
1128
|
-
// A clean write clears any accrued backpressure streak (#743).
|
|
1129
|
-
if (clientKey)
|
|
1130
|
-
this.notifyWriteBackpressureStreak.delete(clientKey);
|
|
1131
|
-
}
|
|
1132
|
-
else {
|
|
1133
|
-
notifyWriteTimedOutServerIds.push(entry.info.id);
|
|
1134
|
-
if (!rejected) {
|
|
1135
|
-
this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
return true;
|
|
1139
|
-
}));
|
|
1140
|
-
if (notifyWriteTimedOutServerIds.length > 0) {
|
|
1664
|
+
try {
|
|
1665
|
+
const spawnedServerIds = spawned.map((entry) => entry.info.id);
|
|
1666
|
+
if (this.shouldSkipTouch(filePath, content, clientScope, diagnosticsMode !== "none", spawnedServerIds)) {
|
|
1141
1667
|
logLatency({
|
|
1142
1668
|
type: "phase",
|
|
1143
|
-
phase: "
|
|
1669
|
+
phase: "lsp_touch_file",
|
|
1144
1670
|
filePath: normalizedPath,
|
|
1145
1671
|
durationMs: Date.now() - startedAt,
|
|
1146
1672
|
metadata: {
|
|
1147
|
-
|
|
1673
|
+
serverCountReady: spawned.length,
|
|
1148
1674
|
clientScope,
|
|
1149
|
-
|
|
1150
|
-
|
|
1675
|
+
diagnosticsMode,
|
|
1676
|
+
source,
|
|
1677
|
+
failureKind: "success",
|
|
1678
|
+
skipped: true,
|
|
1679
|
+
reason: "debounced_unchanged_content",
|
|
1151
1680
|
},
|
|
1152
1681
|
});
|
|
1682
|
+
// #1179: a debounced skip collected nothing — resolve the wrapper's
|
|
1683
|
+
// `.diags` as empty with neither flag (exactly the pre-wrapper `[]`).
|
|
1684
|
+
return { diags: [] };
|
|
1153
1685
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
// paths never set this). Logged in lsp_touch_file metadata.
|
|
1162
|
-
let auxCutOffServerIds;
|
|
1163
|
-
// #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
|
|
1164
|
-
// full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
|
|
1165
|
-
// holds the sync commands' answer when the racing confirm won the wait
|
|
1166
|
-
// (undefined = the race didn't produce an answer; the end-of-wait
|
|
1167
|
-
// fallback below may still fill it in on a timed-out empty result).
|
|
1168
|
-
let tsserverSyncEligible = false;
|
|
1169
|
-
let tsserverSyncConfirmed;
|
|
1170
|
-
if (diagnosticsMode !== "none") {
|
|
1171
|
-
// Resolution: env wins so users can tune the cap without rebuilding.
|
|
1172
|
-
// Otherwise, on the single-server hot path (primary scope), use that
|
|
1173
|
-
// server's own strategy budget (server-strategies.ts) so a fast server
|
|
1174
|
-
// (TypeScript ~1s) isn't held to a flat multi-second wait while a slow
|
|
1175
|
-
// one (rust-analyzer 3s) gets the time it needs — bounded by any caller
|
|
1176
|
-
// ceiling that exists to protect the per-edit pipeline budget (#203).
|
|
1177
|
-
// #573: clientScope "all" (lsp_diagnostics, lens_diagnostics_full) now
|
|
1178
|
-
// gets the same per-server treatment as "with-auxiliary" — each spawned
|
|
1179
|
-
// server (primary + any auxiliaries) is bounded by ITS OWN strategy
|
|
1180
|
-
// budget instead of one flat number shared by every server. This was
|
|
1181
|
-
// never a deliberate "all means wait for the group ceiling" semantic:
|
|
1182
|
-
// #203 introduced perServerTimeout only for the single-server primary
|
|
1183
|
-
// path and left "full"/"all" on the pre-existing flat resolution
|
|
1184
|
-
// ("full/cascade path unchanged"); #242 later added "with-auxiliary"
|
|
1185
|
-
// without revisiting "all". The one property "all" genuinely needs —
|
|
1186
|
-
// the touch's overall detection deadline is the SLOWEST spawned
|
|
1187
|
-
// server's budget, not the fastest — is unaffected: `timeoutMs` below
|
|
1188
|
-
// is always `Math.max(...spawned.map(timeoutFor))` regardless of which
|
|
1189
|
-
// timeoutFor is selected, so a slow auxiliary still gets to run to its
|
|
1190
|
-
// own budget before the touch is logged as timed out. What changes is
|
|
1191
|
-
// only that a fast server's *individual* `waitForDiagnostics` call
|
|
1192
|
-
// (further below) now resolves/times out against its own budget
|
|
1193
|
-
// instead of blocking to the flat multi-server number.
|
|
1194
|
-
const envWait = readEnvDiagnosticsWaitMs();
|
|
1195
|
-
const callerCap = options.maxDiagnosticsWaitMs ?? options.maxClientWaitMs;
|
|
1196
|
-
const modeFloor = diagnosticsMode === "full" ? 3000 : 1200;
|
|
1197
|
-
// #645: resolve each spawned server's "is this the first same-sweep
|
|
1198
|
-
// touch for it" verdict EXACTLY ONCE up front, before `perServerTimeout`
|
|
1199
|
-
// is defined. `SweepIndexGate.consumeFirstTouch` is side-effecting
|
|
1200
|
-
// (it marks the server seen), and `perServerTimeout` below is invoked
|
|
1201
|
-
// twice per server in this call (once to compute the overall
|
|
1202
|
-
// `timeoutMs` deadline, again inside the wait `Promise.all`) — calling
|
|
1203
|
-
// the gate directly from inside `perServerTimeout` would consume the
|
|
1204
|
-
// "first touch" slot on the first of those two calls and read as
|
|
1205
|
-
// already-warm on the second, silently shortchanging the very touch
|
|
1206
|
-
// that was supposed to get the full budget.
|
|
1207
|
-
const sweepFirstTouch = new Map();
|
|
1208
|
-
if (options.sweepIndexGate) {
|
|
1209
|
-
for (const entry of spawned) {
|
|
1210
|
-
const strategy = getStrategy(entry.client.serverId);
|
|
1211
|
-
if (strategy.workspaceIndexing) {
|
|
1212
|
-
sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
// #832: workspace-indexing servers that are classified as silent on
|
|
1217
|
-
// clean do not benefit from the generic cold-indexing floor. Their
|
|
1218
|
-
// configured strategy already gives the first sweep touch a bounded
|
|
1219
|
-
// workspace-index budget (marksman: 1500ms), while the capability
|
|
1220
|
-
// classification proves that a clean push has no affirmative signal to
|
|
1221
|
-
// wait for. Keep this restricted to the workspace-indexing strategy:
|
|
1222
|
-
// TypeScript is also a silent-on-clean push server, but its cold project
|
|
1223
|
-
// load still needs the longer 20s floor.
|
|
1686
|
+
const languageId = getLanguageId(filePath) ?? "plaintext";
|
|
1687
|
+
const silent = options.silent ?? false;
|
|
1688
|
+
// When the same content was already pushed to the LSP within the touch
|
|
1689
|
+
// debounce window, skip the notify — pushing again clears the LSP's
|
|
1690
|
+
// diagnostic cache (via notify.open) and forces it to restart work it
|
|
1691
|
+
// already did. This is what makes the post-write touch + dispatch-lsp-
|
|
1692
|
+
// runner touch sequence expensive on slow TS projects.
|
|
1224
1693
|
//
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1694
|
+
// #743: resolved PER SERVER. A server whose sibling's write stalled last
|
|
1695
|
+
// touch still holds its own debounce entry, so it is skipped here while the
|
|
1696
|
+
// stalled server (which has no entry) gets re-pushed. `notifySkipped` stays
|
|
1697
|
+
// as the file-level "every server was skipped" summary for the logs and the
|
|
1698
|
+
// no-new-version baseline below.
|
|
1699
|
+
const notifySkippedServerIds = new Set(spawnedServerIds.filter((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId)));
|
|
1700
|
+
const notifySkipped = spawned.length > 0 && notifySkippedServerIds.size === spawned.length;
|
|
1701
|
+
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
|
|
1702
|
+
// #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
|
|
1703
|
+
// write gets its OWN notifyWriteBudgetMs budget rather than one shared
|
|
1704
|
+
// deadline over a single Promise.all — otherwise one backpressured server
|
|
1705
|
+
// (stalled stdin) times out the write for the ENTIRE file, flipping every
|
|
1706
|
+
// co-touched healthy server to inconclusive and zeroing its diagnostics.
|
|
1707
|
+
// Bounded so a backpressured write can't hang the caller; on timeout we
|
|
1708
|
+
// proceed — the diagnostics wait below is separately bounded and simply
|
|
1709
|
+
// returns no fresh diagnostics for the server(s) that stalled.
|
|
1710
|
+
//
|
|
1711
|
+
// Holds the serverId of every server whose write did NOT land in time.
|
|
1712
|
+
// The file-level `notifyWriteTimedOut` (logged below) means "at least one
|
|
1713
|
+
// server timed out"; this list carries the per-server detail the
|
|
1714
|
+
// demonstratedReady gate reads so a healthy sibling stays eligible.
|
|
1715
|
+
const notifyWriteTimedOutServerIds = [];
|
|
1716
|
+
if (!notifySkipped) {
|
|
1717
|
+
const budget = notifyWriteBudgetMs();
|
|
1718
|
+
await Promise.all(spawned.map(async (entry) => {
|
|
1719
|
+
// #743: this server already has this content from a recent touch
|
|
1720
|
+
// that landed. Pushing again would clear its diagnostic cache for
|
|
1721
|
+
// nothing — leave its debounce entry (and its original timestamp)
|
|
1722
|
+
// alone so the window still expires naturally.
|
|
1723
|
+
if (notifySkippedServerIds.has(entry.info.id))
|
|
1724
|
+
return;
|
|
1725
|
+
// Same identity as the broken/demonstratedReady maps.
|
|
1726
|
+
const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1727
|
+
let wrote;
|
|
1728
|
+
let rejected = false;
|
|
1234
1729
|
try {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
operationSupport: entry.client.getOperationSupport(),
|
|
1239
|
-
workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
|
|
1240
|
-
advertisedCommands: entry.client.getAdvertisedCommands(),
|
|
1241
|
-
rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
|
|
1242
|
-
launchVariant: entry.client.getLaunchVariant?.(),
|
|
1243
|
-
};
|
|
1244
|
-
if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
|
|
1245
|
-
"tier3-silent") {
|
|
1246
|
-
silentCleanWarmupServers.add(entry.client.serverId);
|
|
1247
|
-
}
|
|
1730
|
+
wrote = await withDeadline(entry.client.notify
|
|
1731
|
+
.open(filePath, content, languageId, undefined, silent)
|
|
1732
|
+
.then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
|
|
1248
1733
|
}
|
|
1249
1734
|
catch {
|
|
1250
|
-
//
|
|
1735
|
+
// The write itself rejected (not backpressure): the content did
|
|
1736
|
+
// not land, so this server is inconclusive for the touch, but a
|
|
1737
|
+
// rejection is not a stdin-backpressure signal and must not count
|
|
1738
|
+
// toward the backpressure demotion streak.
|
|
1739
|
+
rejected = true;
|
|
1740
|
+
}
|
|
1741
|
+
if (wrote === true) {
|
|
1742
|
+
// A clean write clears any accrued backpressure streak (#743).
|
|
1743
|
+
if (clientKey)
|
|
1744
|
+
this.notifyWriteBackpressureStreak.delete(clientKey);
|
|
1745
|
+
// #1253: record the debounce entry for THIS server only, and only
|
|
1746
|
+
// because its own write landed. A server whose write timed out or
|
|
1747
|
+
// rejected falls through without an entry, so the next touch
|
|
1748
|
+
// re-pushes it instead of laundering the failure into a later
|
|
1749
|
+
// touch that looks fully delivered (which the silent-clean gates
|
|
1750
|
+
// would then read as a confirmed clean).
|
|
1751
|
+
this.markTouched(filePath, content, clientScope, entry.info.id);
|
|
1752
|
+
}
|
|
1753
|
+
else {
|
|
1754
|
+
notifyWriteTimedOutServerIds.push(entry.info.id);
|
|
1755
|
+
if (!rejected) {
|
|
1756
|
+
this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
|
|
1757
|
+
}
|
|
1251
1758
|
}
|
|
1759
|
+
}));
|
|
1760
|
+
if (notifyWriteTimedOutServerIds.length > 0) {
|
|
1761
|
+
logLatency({
|
|
1762
|
+
type: "phase",
|
|
1763
|
+
phase: "lsp_notify_timeout",
|
|
1764
|
+
filePath: normalizedPath,
|
|
1765
|
+
durationMs: Date.now() - startedAt,
|
|
1766
|
+
metadata: {
|
|
1767
|
+
source,
|
|
1768
|
+
clientScope,
|
|
1769
|
+
serverCount: spawned.length,
|
|
1770
|
+
timedOutServerIds: notifyWriteTimedOutServerIds,
|
|
1771
|
+
},
|
|
1772
|
+
});
|
|
1252
1773
|
}
|
|
1253
1774
|
}
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
//
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1775
|
+
// File-level flag: at least one server's write timed out (kept for the
|
|
1776
|
+
// conservative touch-wide `inconclusive` merge semantics — see below).
|
|
1777
|
+
const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
|
|
1778
|
+
let diagnosticsTimedOut = false;
|
|
1779
|
+
// R8 (#714): server ids of aux-role servers whose push wait was cut off by
|
|
1780
|
+
// the aux grace window. Undefined when no aux was cut off (primary-only
|
|
1781
|
+
// paths never set this). Logged in lsp_touch_file metadata.
|
|
1782
|
+
let auxCutOffServerIds;
|
|
1783
|
+
// #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
|
|
1784
|
+
// full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
|
|
1785
|
+
// holds the sync commands' answer when the racing confirm won the wait
|
|
1786
|
+
// (undefined = the race didn't produce an answer; the end-of-wait
|
|
1787
|
+
// fallback below may still fill it in on a timed-out empty result).
|
|
1788
|
+
let tsserverSyncEligible = false;
|
|
1789
|
+
let tsserverSyncConfirmed;
|
|
1790
|
+
if (diagnosticsMode !== "none") {
|
|
1791
|
+
// Resolution: env wins so users can tune the cap without rebuilding.
|
|
1792
|
+
// Otherwise, on the single-server hot path (primary scope), use that
|
|
1793
|
+
// server's own strategy budget (wait-policy/strategies.ts) so a fast server
|
|
1794
|
+
// (TypeScript ~1s) isn't held to a flat multi-second wait while a slow
|
|
1795
|
+
// one (rust-analyzer 3s) gets the time it needs — bounded by any caller
|
|
1796
|
+
// ceiling that exists to protect the per-edit pipeline budget (#203).
|
|
1797
|
+
// #573: clientScope "all" (lsp_diagnostics, lens_diagnostics_full) now
|
|
1798
|
+
// gets the same per-server treatment as "with-auxiliary" — each spawned
|
|
1799
|
+
// server (primary + any auxiliaries) is bounded by ITS OWN strategy
|
|
1800
|
+
// budget instead of one flat number shared by every server. This was
|
|
1801
|
+
// never a deliberate "all means wait for the group ceiling" semantic:
|
|
1802
|
+
// #203 introduced perServerTimeout only for the single-server primary
|
|
1803
|
+
// path and left "full"/"all" on the pre-existing flat resolution
|
|
1804
|
+
// ("full/cascade path unchanged"); #242 later added "with-auxiliary"
|
|
1805
|
+
// without revisiting "all". The one property "all" genuinely needs —
|
|
1806
|
+
// the touch's overall detection deadline is the SLOWEST spawned
|
|
1807
|
+
// server's budget, not the fastest — is unaffected: `timeoutMs` below
|
|
1808
|
+
// is always `Math.max(...spawned.map(timeoutFor))` regardless of which
|
|
1809
|
+
// timeoutFor is selected, so a slow auxiliary still gets to run to its
|
|
1810
|
+
// own budget before the touch is logged as timed out. What changes is
|
|
1811
|
+
// only that a fast server's *individual* `waitForDiagnostics` call
|
|
1812
|
+
// (further below) now resolves/times out against its own budget
|
|
1813
|
+
// instead of blocking to the flat multi-server number.
|
|
1814
|
+
const envWait = readEnvDiagnosticsWaitMs();
|
|
1815
|
+
const callerCap = options.maxDiagnosticsWaitMs ?? options.maxClientWaitMs;
|
|
1816
|
+
const modeFloor = diagnosticsMode === "full" ? 3000 : 1200;
|
|
1817
|
+
// #645: resolve each spawned server's "is this the first same-sweep
|
|
1818
|
+
// touch for it" verdict EXACTLY ONCE up front, before `perServerTimeout`
|
|
1819
|
+
// is defined. `SweepIndexGate.consumeFirstTouch` is side-effecting
|
|
1820
|
+
// (it marks the server seen), and `perServerTimeout` below is invoked
|
|
1821
|
+
// twice per server in this call (once to compute the overall
|
|
1822
|
+
// `timeoutMs` deadline, again inside the wait `Promise.all`) — calling
|
|
1823
|
+
// the gate directly from inside `perServerTimeout` would consume the
|
|
1824
|
+
// "first touch" slot on the first of those two calls and read as
|
|
1825
|
+
// already-warm on the second, silently shortchanging the very touch
|
|
1826
|
+
// that was supposed to get the full budget.
|
|
1827
|
+
const sweepFirstTouch = new Map();
|
|
1828
|
+
if (options.sweepIndexGate) {
|
|
1829
|
+
for (const entry of spawned) {
|
|
1830
|
+
const strategy = getStrategy(entry.client.serverId);
|
|
1831
|
+
if (strategy.workspaceIndexing) {
|
|
1832
|
+
sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1274
1835
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1836
|
+
// #832: workspace-indexing servers that are classified as silent on
|
|
1837
|
+
// clean do not benefit from the generic cold-indexing floor. Their
|
|
1838
|
+
// configured strategy already gives the first sweep touch a bounded
|
|
1839
|
+
// workspace-index budget (marksman: 1500ms), while the capability
|
|
1840
|
+
// classification proves that a clean push has no affirmative signal to
|
|
1841
|
+
// wait for. Keep this restricted to the workspace-indexing strategy:
|
|
1842
|
+
// TypeScript is also a silent-on-clean push server, but its cold project
|
|
1843
|
+
// load still needs the longer 20s floor.
|
|
1844
|
+
//
|
|
1845
|
+
// Build this from the live spawned client's capabilities rather than
|
|
1846
|
+
// server id alone. Missing/throwing capability data fails closed, so a
|
|
1847
|
+
// new or ambiguous server keeps the existing generous warm-up budget.
|
|
1848
|
+
const silentCleanWarmupServers = new Set();
|
|
1849
|
+
if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
|
|
1850
|
+
for (const entry of spawned) {
|
|
1851
|
+
const strategy = getStrategy(entry.client.serverId);
|
|
1852
|
+
if (strategy.workspaceIndexing !== true)
|
|
1853
|
+
continue;
|
|
1854
|
+
try {
|
|
1855
|
+
const snapshot = {
|
|
1856
|
+
serverId: entry.client.serverId,
|
|
1857
|
+
root: entry.client.root,
|
|
1858
|
+
operationSupport: entry.client.getOperationSupport(),
|
|
1859
|
+
workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
|
|
1860
|
+
advertisedCommands: entry.client.getAdvertisedCommands(),
|
|
1861
|
+
rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
|
|
1862
|
+
launchVariant: entry.client.getLaunchVariant?.(),
|
|
1863
|
+
};
|
|
1864
|
+
if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
|
|
1865
|
+
"tier3-silent") {
|
|
1866
|
+
silentCleanWarmupServers.add(entry.client.serverId);
|
|
1867
|
+
}
|
|
1287
1868
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
if ((options.warmupAttempt ?? 1) > 1) {
|
|
1291
|
-
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1869
|
+
catch {
|
|
1870
|
+
// Fail closed: capability uncertainty must retain the cold floor.
|
|
1292
1871
|
}
|
|
1293
|
-
return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
|
|
1294
1872
|
}
|
|
1295
|
-
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1296
1873
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
//
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1874
|
+
// Each server gets its OWN deadline, bounded by the caller cap as a
|
|
1875
|
+
// CEILING (never a floor) — so a clean push-silent primary (typescript
|
|
1876
|
+
// ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
|
|
1877
|
+
// slow aux (opengrep) can't override the per-edit cap. Resolves as soon
|
|
1878
|
+
// as a server publishes; this is just its individual deadline. (#242)
|
|
1879
|
+
const perServerTimeout = (serverId) => {
|
|
1880
|
+
const strategy = getStrategy(serverId);
|
|
1881
|
+
let strategyWait = strategy.aggregateWaitMs;
|
|
1882
|
+
// #645: a `workspaceIndexing` server (marksman) only needs the
|
|
1883
|
+
// full budget for the FIRST same-sweep touch to it — every
|
|
1884
|
+
// subsequent touch in this sweep uses the much shorter warm-wait
|
|
1885
|
+
// instead, since the one-time index build only needs to finish
|
|
1886
|
+
// once. `sweepFirstTouch` only has entries when a sweep gate was
|
|
1887
|
+
// passed in AND the strategy is marked, so a per-edit touch
|
|
1888
|
+
// (no gate) or an unmarked server is completely unaffected.
|
|
1889
|
+
const isFirstTouch = sweepFirstTouch.get(serverId);
|
|
1890
|
+
if (isFirstTouch === false && strategy.workspaceIndexing) {
|
|
1891
|
+
strategyWait =
|
|
1892
|
+
strategy.workspaceIndexingWarmWaitMs ??
|
|
1893
|
+
Math.min(300, strategyWait);
|
|
1894
|
+
}
|
|
1895
|
+
if (callerCap !== undefined) {
|
|
1896
|
+
// #669: `ensureWarmForSweep`'s cold-server warm-up wants its cap
|
|
1897
|
+
// to act as a FLOOR (give it at least this much, possibly more
|
|
1898
|
+
// if the strategy already wants more) rather than the normal
|
|
1899
|
+
// ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
|
|
1900
|
+
if (options.warmupOverride) {
|
|
1901
|
+
// #832: a workspace-indexing server already classified as
|
|
1902
|
+
// silent-on-clean uses its strategy's bounded wait on the first
|
|
1903
|
+
// attempt; the generic cold floor is for servers whose cold work
|
|
1904
|
+
// can eventually produce a push answer (notably TypeScript).
|
|
1905
|
+
if (silentCleanWarmupServers.has(serverId)) {
|
|
1906
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1907
|
+
}
|
|
1908
|
+
// #799: only the FIRST warm-up attempt for a cold server gets the
|
|
1909
|
+
// floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
|
|
1910
|
+
if ((options.warmupAttempt ?? 1) > 1) {
|
|
1911
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1912
|
+
}
|
|
1913
|
+
return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
|
|
1914
|
+
}
|
|
1915
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1916
|
+
}
|
|
1917
|
+
return strategyWait > 0 ? strategyWait : modeFloor;
|
|
1918
|
+
};
|
|
1919
|
+
let timeoutFor;
|
|
1920
|
+
if (envWait !== undefined) {
|
|
1921
|
+
// Env override is a single flat cap so users can tune without rebuilding.
|
|
1922
|
+
timeoutFor = () => envWait;
|
|
1340
1923
|
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1924
|
+
else if ((!useAllClients && spawned.length === 1) ||
|
|
1925
|
+
clientScope === "with-auxiliary" ||
|
|
1926
|
+
clientScope === "all") {
|
|
1927
|
+
timeoutFor = perServerTimeout;
|
|
1343
1928
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
// have settled the auxiliaries get at most auxGraceMs before we proceed.
|
|
1350
|
-
// Primary-only and "all"/"primary" scopes are completely unaffected —
|
|
1351
|
-
// they fall through to the original Promise.all path below.
|
|
1352
|
-
//
|
|
1353
|
-
// "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
|
|
1354
|
-
// In the with-auxiliary spawn list, `getClientForFile` returns the
|
|
1355
|
-
// language-primary entry first and `getAuxiliaryClientsForFile` appends
|
|
1356
|
-
// the rest — but we use info.role rather than position so the logic is
|
|
1357
|
-
// correct even if ordering shifts in the future.
|
|
1358
|
-
//
|
|
1359
|
-
// The #707 tsserver sync race operates exclusively on single-server
|
|
1360
|
-
// primary-scope touches (guarded by `clientScope === "primary" &&
|
|
1361
|
-
// spawned.length === 1`), so there is NO interaction with this path.
|
|
1362
|
-
const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
|
|
1363
|
-
spawned.some((e) => e.info.role === "auxiliary");
|
|
1364
|
-
// Per-server wait promises (each already bounded by its own
|
|
1365
|
-
// perServerTimeout — unchanged from before R8).
|
|
1366
|
-
let pressureSnapshots = [];
|
|
1367
|
-
if (shouldPreferPullOnlyDiagnostics()) {
|
|
1368
|
-
try {
|
|
1369
|
-
pressureSnapshots = await this.getCapabilitySnapshots(filePath);
|
|
1929
|
+
else {
|
|
1930
|
+
// Fail-safe for any future clientScope this branch hasn't been
|
|
1931
|
+
// taught about yet — keep the old flat resolution rather than
|
|
1932
|
+
// silently mis-budgeting an unrecognized scope.
|
|
1933
|
+
timeoutFor = () => callerCap ?? modeFloor;
|
|
1370
1934
|
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1935
|
+
// Detection deadline = the slowest individual server's budget.
|
|
1936
|
+
const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
|
|
1937
|
+
// #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
|
|
1938
|
+
// starts. Cheap synchronous gates first (notify succeeded, collecting,
|
|
1939
|
+
// primary scope, `serverId === "typescript"` — the sync commands this
|
|
1940
|
+
// races are tsserver-specific protocol extensions, not a generic
|
|
1941
|
+
// push-only capability, so #799 giving other servers (marksman) the
|
|
1942
|
+
// SAME `silentOnClean` marker must not route them into a sync attempt
|
|
1943
|
+
// that can never succeed for them), then the live capability-snapshot
|
|
1944
|
+
// tier classification (`classifyCascadeWaitTier`, which also excludes
|
|
1945
|
+
// native-ts7 via `launchVariant`). Every other server fails this
|
|
1946
|
+
// synchronous gate and pays ZERO extra work — not even the snapshot
|
|
1947
|
+
// read; a non-typescript `silentOnClean` server instead gets the
|
|
1948
|
+
// generic (non-racing) clean-confirm fallback further below.
|
|
1949
|
+
if (!notifyWriteTimedOut &&
|
|
1950
|
+
options.collectDiagnostics === true &&
|
|
1951
|
+
clientScope === "primary" &&
|
|
1952
|
+
spawned.length === 1 &&
|
|
1953
|
+
spawned[0].client.serverId === "typescript" &&
|
|
1954
|
+
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1955
|
+
try {
|
|
1956
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
1957
|
+
tsserverSyncEligible =
|
|
1958
|
+
classifyCascadeWaitTier(this, filePath, snapshots) ===
|
|
1959
|
+
"tier3-silent";
|
|
1960
|
+
}
|
|
1961
|
+
catch {
|
|
1962
|
+
// Fail-safe: ineligible — today's full wait, no sync attempt.
|
|
1963
|
+
}
|
|
1373
1964
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1965
|
+
const waitStartedAt = Date.now();
|
|
1966
|
+
// R8 (#714): on the with-auxiliary path, apply a bounded aux grace so a
|
|
1967
|
+
// slow auxiliary no longer holds the push wait to its own deadline.
|
|
1968
|
+
// Primary waits resolve on their own per-server budget; once ALL primaries
|
|
1969
|
+
// have settled the auxiliaries get at most auxGraceMs before we proceed.
|
|
1970
|
+
// Primary-only and "all"/"primary" scopes are completely unaffected —
|
|
1971
|
+
// they fall through to the original Promise.all path below.
|
|
1972
|
+
//
|
|
1973
|
+
// "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
|
|
1974
|
+
// In the with-auxiliary spawn list, `getClientForFile` returns the
|
|
1975
|
+
// language-primary entry first and `getAuxiliaryClientsForFile` appends
|
|
1976
|
+
// the rest — but we use info.role rather than position so the logic is
|
|
1977
|
+
// correct even if ordering shifts in the future.
|
|
1978
|
+
//
|
|
1979
|
+
// The #707 tsserver sync race operates exclusively on single-server
|
|
1980
|
+
// primary-scope touches (guarded by `clientScope === "primary" &&
|
|
1981
|
+
// spawned.length === 1`), so there is NO interaction with this path.
|
|
1982
|
+
const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
|
|
1983
|
+
spawned.some((e) => e.info.role === "auxiliary");
|
|
1984
|
+
// Per-server wait promises (each already bounded by its own
|
|
1985
|
+
// perServerTimeout — unchanged from before R8).
|
|
1986
|
+
let pressureSnapshots = [];
|
|
1987
|
+
if (shouldPreferPullOnlyDiagnostics()) {
|
|
1988
|
+
try {
|
|
1989
|
+
pressureSnapshots = await this.getCapabilitySnapshots(filePath);
|
|
1990
|
+
}
|
|
1991
|
+
catch {
|
|
1992
|
+
// Fail-open: missing capability state keeps today's push fallback.
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
const perServerWaits = spawned.map((entry) => {
|
|
1996
|
+
const serverTimeout = timeoutFor(entry.client.serverId);
|
|
1997
|
+
const baseline = diagnosticBaselines.get(entry.client);
|
|
1998
|
+
const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
|
|
1999
|
+
// #743: per-server — a server we DID push to still gets the
|
|
2000
|
+
// version-baseline wait even when a sibling was debounced away.
|
|
2001
|
+
const wait = !notifySkippedServerIds.has(entry.info.id) && Number.isFinite(baseline)
|
|
1385
2002
|
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
1386
|
-
|
|
2003
|
+
minVersion: baseline,
|
|
2004
|
+
...(pullOnly && { pullOnly: true }),
|
|
1387
2005
|
})
|
|
1388
|
-
:
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
:
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
if (
|
|
1418
|
-
|
|
2006
|
+
: pullOnly
|
|
2007
|
+
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
2008
|
+
pullOnly: true,
|
|
2009
|
+
})
|
|
2010
|
+
: entry.client.waitForDiagnostics(filePath, serverTimeout);
|
|
2011
|
+
return wait.catch(() => undefined);
|
|
2012
|
+
});
|
|
2013
|
+
// The push wait — same per-server budget composition as before #707;
|
|
2014
|
+
// only the awaiting changed (assigned so it can be raced below).
|
|
2015
|
+
let pushWaitSettled = false;
|
|
2016
|
+
const pushWait = hasTouchAuxiliaries
|
|
2017
|
+
? (() => {
|
|
2018
|
+
// Primary waits: all non-auxiliary servers.
|
|
2019
|
+
const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
|
|
2020
|
+
// Aux waits: auxiliary servers (advisory).
|
|
2021
|
+
const auxWaits = perServerWaits
|
|
2022
|
+
.map((p, i) => spawned[i].info.role === "auxiliary"
|
|
2023
|
+
? { promise: p, serverId: spawned[i].info.id }
|
|
2024
|
+
: null)
|
|
2025
|
+
.filter((x) => x !== null);
|
|
2026
|
+
const auxGraceMs = readEnvAuxGraceMs() ?? 500;
|
|
2027
|
+
// After all primaries settle, give auxiliaries at most auxGraceMs.
|
|
2028
|
+
// Late aux results are dropped from the wait (advisory only — they
|
|
2029
|
+
// land in the client cache and surface on the next edit); aux servers
|
|
2030
|
+
// that did answer within the grace are included automatically since
|
|
2031
|
+
// their waitForDiagnostics already resolved. The cut-off server ids
|
|
2032
|
+
// are logged in the latency metadata (lsp_touch_file phase, field
|
|
2033
|
+
// `auxCutOffServerIds`).
|
|
2034
|
+
return Promise.all(primaryWaits).then(async () => {
|
|
2035
|
+
if (auxWaits.length === 0)
|
|
2036
|
+
return;
|
|
2037
|
+
const auxTimeout = new Promise((resolve) => {
|
|
2038
|
+
const t = setTimeout(() => resolve("timeout"), auxGraceMs);
|
|
2039
|
+
if (typeof t === "object" && "unref" in t)
|
|
2040
|
+
t.unref?.();
|
|
2041
|
+
});
|
|
2042
|
+
const auxAll = Promise.all(auxWaits.map((a) => a.promise)).then(() => "done");
|
|
2043
|
+
const outcome = await Promise.race([auxAll, auxTimeout]);
|
|
2044
|
+
if (outcome === "timeout") {
|
|
2045
|
+
// Record which auxiliaries did NOT finish in time.
|
|
2046
|
+
const unfinished = [];
|
|
2047
|
+
for (const a of auxWaits) {
|
|
2048
|
+
let done = false;
|
|
2049
|
+
// Check synchronously if already resolved by racing against
|
|
2050
|
+
// a resolved promise.
|
|
2051
|
+
await Promise.race([
|
|
2052
|
+
a.promise.then(() => {
|
|
2053
|
+
done = true;
|
|
2054
|
+
}),
|
|
2055
|
+
Promise.resolve(),
|
|
2056
|
+
]);
|
|
2057
|
+
if (!done)
|
|
2058
|
+
unfinished.push(a.serverId);
|
|
2059
|
+
}
|
|
2060
|
+
if (unfinished.length > 0)
|
|
2061
|
+
auxCutOffServerIds = unfinished;
|
|
2062
|
+
}
|
|
1419
2063
|
});
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
2064
|
+
})()
|
|
2065
|
+
: Promise.all(perServerWaits).then(() => { });
|
|
2066
|
+
pushWait.then(() => {
|
|
2067
|
+
pushWaitSettled = true;
|
|
2068
|
+
});
|
|
2069
|
+
if (tsserverSyncEligible) {
|
|
2070
|
+
// #707 racing variant: rather than burning the full push-wait budget
|
|
2071
|
+
// on a silent-on-clean server (which by definition never answers on a
|
|
2072
|
+
// clean file), race the push wait against a grace-delayed sync
|
|
2073
|
+
// confirm. The grace (default 300ms, PI_LENS_TSSERVER_SYNC_GRACE_MS)
|
|
2074
|
+
// gives a genuinely dirty file's push a head start: if diagnostics
|
|
2075
|
+
// arrive before the grace elapses, the sync request never goes out —
|
|
2076
|
+
// zero new latency or requests on the push-answers path.
|
|
2077
|
+
//
|
|
2078
|
+
// Race semantics:
|
|
2079
|
+
// - sync answers first → that's the confirmed result (clean OR
|
|
2080
|
+
// dirty — the sync commands return the file's real syntactic +
|
|
2081
|
+
// semantic state, so a dirty-file win is still correct and its
|
|
2082
|
+
// findings are surfaced, never discarded).
|
|
2083
|
+
// - push settles first → push wins; a still-in-flight sync outcome
|
|
2084
|
+
// is discarded (the racer checks `pushWaitSettled` after the
|
|
2085
|
+
// call returns and drops its own result).
|
|
2086
|
+
// - sync unavailable/fails → the racer parks on a never-resolving
|
|
2087
|
+
// promise so the race is decided by the push wait's own budget,
|
|
2088
|
+
// exactly today's behavior (the end-of-wait fallback below still
|
|
2089
|
+
// gets its shot on a timed-out empty result).
|
|
2090
|
+
// The racer never rejects (every failure path is caught), so the
|
|
2091
|
+
// losing promise can never surface as an unhandled rejection.
|
|
2092
|
+
const graceMs = readTsserverSyncGraceMs();
|
|
2093
|
+
const primaryClient = spawned[0].client;
|
|
2094
|
+
// Resolves with the sync commands' diagnostics when the confirm
|
|
2095
|
+
// succeeds; parks on a never-resolving promise on EVERY other path
|
|
2096
|
+
// (push already answered, sync unavailable/failed, push won while
|
|
2097
|
+
// in flight) so the race is then decided by the push wait's own
|
|
2098
|
+
// budget — exactly today's behavior.
|
|
2099
|
+
const syncRacer = (async () => {
|
|
2100
|
+
await new Promise((resolve) => {
|
|
2101
|
+
const timer = setTimeout(resolve, graceMs);
|
|
2102
|
+
timer.unref?.();
|
|
2103
|
+
});
|
|
2104
|
+
// Push already answered (settled, or published diagnostics that
|
|
2105
|
+
// its wait is about to settle on) — nothing to confirm, no sync
|
|
2106
|
+
// request goes out.
|
|
2107
|
+
if (pushWaitSettled ||
|
|
2108
|
+
primaryClient.getDiagnostics(filePath).length > 0) {
|
|
2109
|
+
return new Promise(() => { });
|
|
2110
|
+
}
|
|
2111
|
+
try {
|
|
2112
|
+
const result = await attemptTsserverSyncDiagnostics(filePath, this);
|
|
2113
|
+
if (result === undefined || pushWaitSettled) {
|
|
2114
|
+
// Sync unavailable/failed, or push won while the sync call
|
|
2115
|
+
// was in flight — drop the sync outcome and let the push
|
|
2116
|
+
// wait decide the race.
|
|
2117
|
+
return new Promise(() => { });
|
|
1437
2118
|
}
|
|
1438
|
-
|
|
1439
|
-
auxCutOffServerIds = unfinished;
|
|
2119
|
+
return result;
|
|
1440
2120
|
}
|
|
2121
|
+
catch {
|
|
2122
|
+
return new Promise(() => { });
|
|
2123
|
+
}
|
|
2124
|
+
})();
|
|
2125
|
+
const raceOutcome = await Promise.race([
|
|
2126
|
+
pushWait.then(() => undefined),
|
|
2127
|
+
syncRacer,
|
|
2128
|
+
]);
|
|
2129
|
+
if (raceOutcome !== undefined) {
|
|
2130
|
+
tsserverSyncConfirmed = raceOutcome;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
else {
|
|
2134
|
+
await pushWait;
|
|
2135
|
+
}
|
|
2136
|
+
const waitedMs = Date.now() - waitStartedAt;
|
|
2137
|
+
if (tsserverSyncConfirmed !== undefined) {
|
|
2138
|
+
// #707: the racing sync confirm won — a definitive answer well under
|
|
2139
|
+
// the push-wait budget. Not a timeout, not inconclusive.
|
|
2140
|
+
logLatency({
|
|
2141
|
+
type: "phase",
|
|
2142
|
+
phase: "lsp_tsserver_sync_confirm",
|
|
2143
|
+
filePath: normalizedPath,
|
|
2144
|
+
durationMs: waitedMs,
|
|
2145
|
+
metadata: {
|
|
2146
|
+
source,
|
|
2147
|
+
serverId: spawned[0]?.client.serverId,
|
|
2148
|
+
clientScope,
|
|
2149
|
+
diagnosticsMode,
|
|
2150
|
+
mode: "race",
|
|
2151
|
+
confirmedDiagnosticCount: tsserverSyncConfirmed.length,
|
|
2152
|
+
budgetMs: timeoutMs,
|
|
2153
|
+
savedVsBudgetMs: Math.max(0, timeoutMs - waitedMs),
|
|
2154
|
+
},
|
|
1441
2155
|
});
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
// semantic state, so a dirty-file win is still correct and its
|
|
1460
|
-
// findings are surfaced, never discarded).
|
|
1461
|
-
// - push settles first → push wins; a still-in-flight sync outcome
|
|
1462
|
-
// is discarded (the racer checks `pushWaitSettled` after the
|
|
1463
|
-
// call returns and drops its own result).
|
|
1464
|
-
// - sync unavailable/fails → the racer parks on a never-resolving
|
|
1465
|
-
// promise so the race is decided by the push wait's own budget,
|
|
1466
|
-
// exactly today's behavior (the end-of-wait fallback below still
|
|
1467
|
-
// gets its shot on a timed-out empty result).
|
|
1468
|
-
// The racer never rejects (every failure path is caught), so the
|
|
1469
|
-
// losing promise can never surface as an unhandled rejection.
|
|
1470
|
-
const graceMs = readTsserverSyncGraceMs();
|
|
1471
|
-
const primaryClient = spawned[0].client;
|
|
1472
|
-
// Resolves with the sync commands' diagnostics when the confirm
|
|
1473
|
-
// succeeds; parks on a never-resolving promise on EVERY other path
|
|
1474
|
-
// (push already answered, sync unavailable/failed, push won while
|
|
1475
|
-
// in flight) so the race is then decided by the push wait's own
|
|
1476
|
-
// budget — exactly today's behavior.
|
|
1477
|
-
const syncRacer = (async () => {
|
|
1478
|
-
await new Promise((resolve) => {
|
|
1479
|
-
const timer = setTimeout(resolve, graceMs);
|
|
1480
|
-
timer.unref?.();
|
|
1481
|
-
});
|
|
1482
|
-
// Push already answered (settled, or published diagnostics that
|
|
1483
|
-
// its wait is about to settle on) — nothing to confirm, no sync
|
|
1484
|
-
// request goes out.
|
|
1485
|
-
if (pushWaitSettled ||
|
|
1486
|
-
primaryClient.getDiagnostics(filePath).length > 0) {
|
|
1487
|
-
return new Promise(() => { });
|
|
2156
|
+
}
|
|
2157
|
+
else if (waitedMs + 20 >= timeoutMs) {
|
|
2158
|
+
// Within ~20 ms of the configured budget we treat it as a timeout;
|
|
2159
|
+
// the LSP didn't beat the cap. Diagnostics that arrive late still
|
|
2160
|
+
// land in the client's cache and surface on the next edit.
|
|
2161
|
+
diagnosticsTimedOut = true;
|
|
2162
|
+
for (const entry of spawned) {
|
|
2163
|
+
incrementDegradationCount({
|
|
2164
|
+
kind: "lsp-diagnostics-timeout",
|
|
2165
|
+
// `info.id` is the authoritative server identity carried by
|
|
2166
|
+
// every spawned entry. The client test doubles (and some
|
|
2167
|
+
// lightweight clients) need not expose a serverId property;
|
|
2168
|
+
// ledger recording must never abort the touch or alter #570's
|
|
2169
|
+
// timeout-preserves-last-known-diagnostics semantics.
|
|
2170
|
+
subject: entry.info.id,
|
|
2171
|
+
reason: "diagnostics wait timed out",
|
|
2172
|
+
});
|
|
1488
2173
|
}
|
|
2174
|
+
logLatency({
|
|
2175
|
+
type: "phase",
|
|
2176
|
+
phase: "lsp_diagnostics_timeout",
|
|
2177
|
+
filePath: normalizedPath,
|
|
2178
|
+
durationMs: waitedMs,
|
|
2179
|
+
metadata: {
|
|
2180
|
+
source,
|
|
2181
|
+
clientScope,
|
|
2182
|
+
diagnosticsMode,
|
|
2183
|
+
timeoutMs,
|
|
2184
|
+
},
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
// #814: capability-aware AGGREGATE wait — generalize #799's
|
|
2188
|
+
// single-server (`clientScope === "primary" && spawned.length === 1`)
|
|
2189
|
+
// silent-clean confirm to multi-server `clientScope: "all"` touches
|
|
2190
|
+
// (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
|
|
2191
|
+
// `serverScope: "all"`). #799's gate never fires here (it's scoped to
|
|
2192
|
+
// the primary hot path), so a scope-"all" touch where every OTHER
|
|
2193
|
+
// spawned server already answered but one push-only `silentOnClean`
|
|
2194
|
+
// server (marksman on a clean markdown file) never publishes still
|
|
2195
|
+
// reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
|
|
2196
|
+
// though the "silence" is exactly what that server's own known
|
|
2197
|
+
// clean-behavior predicts — not an unresolved question.
|
|
2198
|
+
//
|
|
2199
|
+
// A spawned server counts as "still outstanding" when nothing landed
|
|
2200
|
+
// in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
|
|
2201
|
+
// is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
|
|
2202
|
+
// that file's entry as part of the didOpen/didChange this touch just
|
|
2203
|
+
// sent, so a present entry can only be a FRESH answer (found or a real
|
|
2204
|
+
// confirmed-empty push/pull), never a stale one bleeding through from
|
|
2205
|
+
// an earlier touch. This is the same "did anything publish for this
|
|
2206
|
+
// file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
|
|
2207
|
+
// already trusts (#240 doctrine) — reused here, not reinvented.
|
|
2208
|
+
//
|
|
2209
|
+
// The touch stays inconclusive unless EVERY still-outstanding server
|
|
2210
|
+
// is classified `tier3-silent` (push-only + `silentOnClean`, the same
|
|
2211
|
+
// `classifyServerWaitTier` rule the single-server gate below and the
|
|
2212
|
+
// cascade lane use) — one ordinary push-only straggler (still
|
|
2213
|
+
// genuinely analyzing) or a pull-capable server that never answered
|
|
2214
|
+
// keeps the touch cautious, matching #799's "err toward caution"
|
|
2215
|
+
// posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
|
|
2216
|
+
// plus the per-server re-check below are the same "the notify write
|
|
2217
|
+
// must have actually landed" conservatism #799 established — a
|
|
2218
|
+
// server's silence is only evidence of "clean" when we know it saw
|
|
2219
|
+
// the new content.
|
|
2220
|
+
if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
|
|
1489
2221
|
try {
|
|
1490
|
-
const
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
2222
|
+
const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
|
|
2223
|
+
!entry.client.getAllDiagnostics().has(normalizedPath));
|
|
2224
|
+
if (outstanding.length > 0) {
|
|
2225
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
2226
|
+
const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
|
|
2227
|
+
if (allSilent) {
|
|
2228
|
+
// #1277: the static tier3-silent classification alone can't
|
|
2229
|
+
// tell a genuinely clean server from one that accepted the
|
|
2230
|
+
// notify write and then wedged — both look identical to
|
|
2231
|
+
// `classifyServerWaitTier`, which only reads the capability
|
|
2232
|
+
// snapshot, never the server's actual current responsiveness.
|
|
2233
|
+
// Require every still-outstanding server to answer a cheap
|
|
2234
|
+
// bounded round-trip before trusting the silence as clean;
|
|
2235
|
+
// any server that doesn't respond in time keeps the touch
|
|
2236
|
+
// inconclusive rather than confirming a possibly-dead server
|
|
2237
|
+
// clean.
|
|
2238
|
+
const liveness = await Promise.all(outstanding.map((entry) => (entry.client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false)));
|
|
2239
|
+
if (liveness.every(Boolean)) {
|
|
2240
|
+
diagnosticsTimedOut = false;
|
|
2241
|
+
logLatency({
|
|
2242
|
+
type: "phase",
|
|
2243
|
+
phase: "lsp_silent_clean_confirm",
|
|
2244
|
+
filePath: normalizedPath,
|
|
2245
|
+
durationMs: Date.now() - startedAt,
|
|
2246
|
+
metadata: {
|
|
2247
|
+
source,
|
|
2248
|
+
clientScope,
|
|
2249
|
+
diagnosticsMode,
|
|
2250
|
+
aggregate: true,
|
|
2251
|
+
serverIds: outstanding.map((entry) => entry.client.serverId),
|
|
2252
|
+
},
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
1496
2256
|
}
|
|
1497
|
-
return result;
|
|
1498
2257
|
}
|
|
1499
2258
|
catch {
|
|
1500
|
-
|
|
2259
|
+
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's
|
|
2260
|
+
// inconclusive behavior, exactly like the single-server gate.
|
|
1501
2261
|
}
|
|
1502
|
-
})();
|
|
1503
|
-
const raceOutcome = await Promise.race([
|
|
1504
|
-
pushWait.then(() => undefined),
|
|
1505
|
-
syncRacer,
|
|
1506
|
-
]);
|
|
1507
|
-
if (raceOutcome !== undefined) {
|
|
1508
|
-
tsserverSyncConfirmed = raceOutcome;
|
|
1509
2262
|
}
|
|
1510
2263
|
}
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
2264
|
+
// #707: when the racing sync confirm won the wait, its answer IS the
|
|
2265
|
+
// collected result — the file's real syntactic + semantic state straight
|
|
2266
|
+
// from tsserver (clean = [], dirty = real findings that a silentOnClean
|
|
2267
|
+
// server had computed but never published). Otherwise merge the push
|
|
2268
|
+
// diagnostics from the client cache as always.
|
|
2269
|
+
let collected = options.collectDiagnostics
|
|
2270
|
+
? tsserverSyncConfirmed !== undefined
|
|
2271
|
+
? mergeLspDiagnostics(tsserverSyncConfirmed)
|
|
2272
|
+
: mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
|
|
2273
|
+
: undefined;
|
|
2274
|
+
// #1095 (P3-b): whether `collected` came from a tsserver sync confirm
|
|
2275
|
+
// (`tsserverSyncRequest`) rather than the publish cache. A sync-confirmed
|
|
2276
|
+
// result is authoritative for the CURRENT buffer but is NOT tied to the
|
|
2277
|
+
// publish-path content binding (`diagnosticBindings`, set on publish), so
|
|
2278
|
+
// composing that binding here could let a STALE publish fingerprint demote a
|
|
2279
|
+
// genuinely-fresh sync answer to `false`. The end-of-wait fallback below can
|
|
2280
|
+
// also set this. When true, the binding is surfaced as "unknown" (honest,
|
|
2281
|
+
// non-demoting) rather than the stale publish binding.
|
|
2282
|
+
let syncConfirmed = tsserverSyncConfirmed !== undefined;
|
|
2283
|
+
// #707 end-of-wait fallback: when the racing confirm did NOT decide the
|
|
2284
|
+
// wait (sync unavailable/failed mid-race, or push resolved as a bare
|
|
2285
|
+
// timeout) and the wait timed out with an empty result on an eligible
|
|
2286
|
+
// touch, give the sync clean-confirm one last shot before reporting
|
|
2287
|
+
// inconclusive. `tsserverSyncEligible` already encodes every gate (notify
|
|
2288
|
+
// succeeded, collecting, primary scope, tier3-silent classic typescript —
|
|
2289
|
+
// native-ts7 excluded by `classifyCascadeWaitTier`). If the sync call
|
|
2290
|
+
// answers (even with an empty body, which is a confirmed clean), we use
|
|
2291
|
+
// those diagnostics as the confirmed result and clear the
|
|
2292
|
+
// `diagnosticsTimedOut` flag so the touch is no longer treated as
|
|
2293
|
+
// inconclusive. Sync diagnostics on a dirty file are surfaced, not
|
|
2294
|
+
// discarded. If the sync call fails or is unavailable, we fall through to
|
|
2295
|
+
// today's behavior: `inconclusive` = true, `collected` unchanged. This
|
|
2296
|
+
// turns "unconfirmed after ~1000ms" into "confirmed at ~wait+sync-RTT"
|
|
2297
|
+
// even when the race path couldn't answer.
|
|
2298
|
+
if (diagnosticsTimedOut &&
|
|
2299
|
+
tsserverSyncEligible &&
|
|
2300
|
+
collected !== undefined &&
|
|
2301
|
+
collected.length === 0) {
|
|
2302
|
+
try {
|
|
2303
|
+
const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
|
|
2304
|
+
if (syncResult !== undefined) {
|
|
2305
|
+
// Sync answered — confirmed result (clean or with diagnostics).
|
|
2306
|
+
// Clear the timed-out flag so the touch is no longer inconclusive.
|
|
2307
|
+
diagnosticsTimedOut = false;
|
|
2308
|
+
syncConfirmed = true;
|
|
2309
|
+
collected = syncResult.length > 0
|
|
2310
|
+
? mergeLspDiagnostics(syncResult)
|
|
2311
|
+
: [];
|
|
2312
|
+
logLatency({
|
|
2313
|
+
type: "phase",
|
|
2314
|
+
phase: "lsp_tsserver_sync_confirm",
|
|
2315
|
+
filePath: normalizedPath,
|
|
2316
|
+
durationMs: Date.now() - startedAt,
|
|
2317
|
+
metadata: {
|
|
2318
|
+
source,
|
|
2319
|
+
clientScope,
|
|
2320
|
+
diagnosticsMode,
|
|
2321
|
+
mode: "end-of-wait",
|
|
2322
|
+
confirmedDiagnosticCount: collected.length,
|
|
2323
|
+
},
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
catch {
|
|
2328
|
+
// Any failure here falls through to today's inconclusive behavior.
|
|
2329
|
+
}
|
|
1551
2330
|
}
|
|
1552
|
-
// #
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1556
|
-
//
|
|
1557
|
-
// the
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
//
|
|
1563
|
-
//
|
|
1564
|
-
//
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
2331
|
+
// #799: generalize the "silent-clean push-only" confirm beyond
|
|
2332
|
+
// typescript's active sync-command race above. That mechanism is
|
|
2333
|
+
// TS-specific (`attemptTsserverSyncDiagnostics` races an actual
|
|
2334
|
+
// `typescript.tsserverRequest` — no equivalent protocol exists for
|
|
2335
|
+
// e.g. marksman) and is now scoped to `serverId === "typescript"`
|
|
2336
|
+
// only (see the gate above), so it never fires for another
|
|
2337
|
+
// `silentOnClean` server. This is the generic fallback for those
|
|
2338
|
+
// servers: if the wait ran its full budget with a successful notify
|
|
2339
|
+
// write and nothing published, and the live capability snapshot
|
|
2340
|
+
// classifies this touch as tier3-silent (push-only + `silentOnClean`,
|
|
2341
|
+
// #458's `classifyCascadeWaitTier`), that is not "still working" —
|
|
2342
|
+
// `silentOnClean` means by definition this server publishes NOTHING
|
|
2343
|
+
// on a clean transition, so a timeout under those conditions IS the
|
|
2344
|
+
// confirmed-clean answer. `!tsserverSyncEligible` keeps this from
|
|
2345
|
+
// ever double-deciding typescript's own touches — when the sync race
|
|
2346
|
+
// was attempted and failed/was unavailable, typescript's existing
|
|
2347
|
+
// "falls through to inconclusive, unchanged" contract (#707) is
|
|
2348
|
+
// preserved exactly; typescript touches that never enter that gate
|
|
2349
|
+
// (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
|
|
2350
|
+
// warm-up call) are still eligible here as a genuine bonus fix. Scoped
|
|
2351
|
+
// to `clientScope === "primary"`/`spawned.length === 1` exactly like
|
|
2352
|
+
// the sync-eligible gate above (and like `ensureWarmForSweep`'s own
|
|
2353
|
+
// `clientScope: "primary"` warm-up touch) so a multi-server
|
|
2354
|
+
// with-auxiliary/all touch — where a partial timeout must stay
|
|
2355
|
+
// cautious per the doc below — is never affected.
|
|
1573
2356
|
//
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
// `
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
2357
|
+
// #814: this is now a SPECIAL CASE of the more general per-server gate
|
|
2358
|
+
// above (the `clientScope === "all"` block right before the diagnostics-
|
|
2359
|
+
// wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
|
|
2360
|
+
// server is tier3-silent" collapses to exactly this single-server check.
|
|
2361
|
+
// Left in place unchanged (rather than deleted/rewritten to delegate to
|
|
2362
|
+
// the new gate) per #814's scope: a future cleanup could fold this block
|
|
2363
|
+
// into the general one once both have soaked, but that's a separate,
|
|
2364
|
+
// lower-risk follow-up, not bundled into this fix.
|
|
2365
|
+
if (diagnosticsTimedOut &&
|
|
2366
|
+
!notifyWriteTimedOut &&
|
|
2367
|
+
!tsserverSyncEligible &&
|
|
2368
|
+
clientScope === "primary" &&
|
|
2369
|
+
spawned.length === 1 &&
|
|
2370
|
+
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1586
2371
|
try {
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
2372
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
2373
|
+
if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
|
|
2374
|
+
// #1277: same liveness precondition as the aggregate gate above —
|
|
2375
|
+
// `tier3-silent` is a static capability classification and can't
|
|
2376
|
+
// distinguish "silent because clean" from "silent because wedged".
|
|
2377
|
+
// A cheap bounded round-trip proves the server is still actually
|
|
2378
|
+
// responding before its silence is trusted as a clean confirm; a
|
|
2379
|
+
// server that doesn't answer in time leaves the touch inconclusive.
|
|
2380
|
+
const alive = await (spawned[0].client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false);
|
|
2381
|
+
if (alive) {
|
|
1593
2382
|
diagnosticsTimedOut = false;
|
|
2383
|
+
if (collected !== undefined)
|
|
2384
|
+
collected = mergeLspDiagnostics([]);
|
|
1594
2385
|
logLatency({
|
|
1595
2386
|
type: "phase",
|
|
1596
2387
|
phase: "lsp_silent_clean_confirm",
|
|
@@ -1600,238 +2391,155 @@ export class LSPService {
|
|
|
1600
2391
|
source,
|
|
1601
2392
|
clientScope,
|
|
1602
2393
|
diagnosticsMode,
|
|
1603
|
-
|
|
1604
|
-
serverIds: outstanding.map((entry) => entry.client.serverId),
|
|
2394
|
+
serverId: spawned[0].client.serverId,
|
|
1605
2395
|
},
|
|
1606
2396
|
});
|
|
1607
2397
|
}
|
|
1608
2398
|
}
|
|
1609
2399
|
}
|
|
1610
2400
|
catch {
|
|
1611
|
-
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's
|
|
1612
|
-
//
|
|
2401
|
+
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
|
|
2402
|
+
// behavior.
|
|
1613
2403
|
}
|
|
1614
2404
|
}
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
:
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
collected !== undefined &&
|
|
1644
|
-
collected.length === 0) {
|
|
1645
|
-
try {
|
|
1646
|
-
const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
|
|
1647
|
-
if (syncResult !== undefined) {
|
|
1648
|
-
// Sync answered — confirmed result (clean or with diagnostics).
|
|
1649
|
-
// Clear the timed-out flag so the touch is no longer inconclusive.
|
|
1650
|
-
diagnosticsTimedOut = false;
|
|
1651
|
-
collected = syncResult.length > 0
|
|
1652
|
-
? mergeLspDiagnostics(syncResult)
|
|
1653
|
-
: [];
|
|
1654
|
-
logLatency({
|
|
1655
|
-
type: "phase",
|
|
1656
|
-
phase: "lsp_tsserver_sync_confirm",
|
|
1657
|
-
filePath: normalizedPath,
|
|
1658
|
-
durationMs: Date.now() - startedAt,
|
|
1659
|
-
metadata: {
|
|
1660
|
-
source,
|
|
1661
|
-
clientScope,
|
|
1662
|
-
diagnosticsMode,
|
|
1663
|
-
mode: "end-of-wait",
|
|
1664
|
-
confirmedDiagnosticCount: collected.length,
|
|
1665
|
-
},
|
|
1666
|
-
});
|
|
2405
|
+
// A touch is inconclusive when EITHER the notify write or the
|
|
2406
|
+
// diagnostics wait hit their deadline for ANY of the spawned servers
|
|
2407
|
+
// (these flags are touch-wide, covering the whole `Promise.all` over
|
|
2408
|
+
// `spawned` — see the field doc on the return type). We deliberately
|
|
2409
|
+
// err toward caution here: `collected` merges diagnostics across every
|
|
2410
|
+
// spawned server, so even a partial timeout (e.g. a slow auxiliary
|
|
2411
|
+
// while the primary answered) means the merged result may be missing
|
|
2412
|
+
// findings that just hadn't arrived yet — it must not be trusted as a
|
|
2413
|
+
// confirmed answer.
|
|
2414
|
+
const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
|
|
2415
|
+
// #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
|
|
2416
|
+
// "actually warm" signal `ensureWarmForSweep` waits for — mark every
|
|
2417
|
+
// spawned server so a later sweep in this session sees the check as a
|
|
2418
|
+
// no-op instead of paying the warm-up round trip again.
|
|
2419
|
+
//
|
|
2420
|
+
// #743: the diagnostics wait is a blanket (touch-wide) gate, but the
|
|
2421
|
+
// notify-write timeout is now PER-SERVER — a healthy server whose sibling's
|
|
2422
|
+
// write stalled must still be eligible, so only servers whose OWN write
|
|
2423
|
+
// timed out are skipped here (rather than gating the whole loop on the
|
|
2424
|
+
// file-level `inconclusive`).
|
|
2425
|
+
const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
|
|
2426
|
+
if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
|
|
2427
|
+
for (const entry of spawned) {
|
|
2428
|
+
if (notifyTimedOutServerIds.has(entry.info.id))
|
|
2429
|
+
continue;
|
|
2430
|
+
const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
2431
|
+
if (key)
|
|
2432
|
+
this.markDemonstratedReadyKey(key);
|
|
1667
2433
|
}
|
|
1668
2434
|
}
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
// ever double-deciding typescript's own touches — when the sync race
|
|
1688
|
-
// was attempted and failed/was unavailable, typescript's existing
|
|
1689
|
-
// "falls through to inconclusive, unchanged" contract (#707) is
|
|
1690
|
-
// preserved exactly; typescript touches that never enter that gate
|
|
1691
|
-
// (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
|
|
1692
|
-
// warm-up call) are still eligible here as a genuine bonus fix. Scoped
|
|
1693
|
-
// to `clientScope === "primary"`/`spawned.length === 1` exactly like
|
|
1694
|
-
// the sync-eligible gate above (and like `ensureWarmForSweep`'s own
|
|
1695
|
-
// `clientScope: "primary"` warm-up touch) so a multi-server
|
|
1696
|
-
// with-auxiliary/all touch — where a partial timeout must stay
|
|
1697
|
-
// cautious per the doc below — is never affected.
|
|
1698
|
-
//
|
|
1699
|
-
// #814: this is now a SPECIAL CASE of the more general per-server gate
|
|
1700
|
-
// above (the `clientScope === "all"` block right before the diagnostics-
|
|
1701
|
-
// wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
|
|
1702
|
-
// server is tier3-silent" collapses to exactly this single-server check.
|
|
1703
|
-
// Left in place unchanged (rather than deleted/rewritten to delegate to
|
|
1704
|
-
// the new gate) per #814's scope: a future cleanup could fold this block
|
|
1705
|
-
// into the general one once both have soaked, but that's a separate,
|
|
1706
|
-
// lower-risk follow-up, not bundled into this fix.
|
|
1707
|
-
if (diagnosticsTimedOut &&
|
|
1708
|
-
!notifyWriteTimedOut &&
|
|
1709
|
-
!tsserverSyncEligible &&
|
|
1710
|
-
clientScope === "primary" &&
|
|
1711
|
-
spawned.length === 1 &&
|
|
1712
|
-
getStrategy(spawned[0].client.serverId).silentOnClean === true) {
|
|
1713
|
-
try {
|
|
1714
|
-
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
1715
|
-
if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
|
|
1716
|
-
diagnosticsTimedOut = false;
|
|
1717
|
-
if (collected !== undefined)
|
|
1718
|
-
collected = mergeLspDiagnostics([]);
|
|
1719
|
-
logLatency({
|
|
1720
|
-
type: "phase",
|
|
1721
|
-
phase: "lsp_silent_clean_confirm",
|
|
1722
|
-
filePath: normalizedPath,
|
|
1723
|
-
durationMs: Date.now() - startedAt,
|
|
1724
|
-
metadata: {
|
|
1725
|
-
source,
|
|
1726
|
-
clientScope,
|
|
1727
|
-
diagnosticsMode,
|
|
1728
|
-
serverId: spawned[0].client.serverId,
|
|
1729
|
-
},
|
|
1730
|
-
});
|
|
2435
|
+
// Prime the last-known cache WITH the hash of the content we just synced,
|
|
2436
|
+
// so a hot-path consumer (actionable-warnings at turn_end) can verify the
|
|
2437
|
+
// cached diagnostics are for the current bytes before reusing them instead
|
|
2438
|
+
// of paying for a second open+wait. Only when we actually collected — a
|
|
2439
|
+
// non-collecting touch (didChange-only) leaves the prior entry intact.
|
|
2440
|
+
// Skip this entirely when the touch was inconclusive: an unconfirmed
|
|
2441
|
+
// empty `collected` must never erase a previously-confirmed non-empty
|
|
2442
|
+
// record (that's the #570 bug — a timeout silently reporting as clean
|
|
2443
|
+
// and wiping out known-good diagnostic state).
|
|
2444
|
+
if (collected !== undefined && !inconclusive) {
|
|
2445
|
+
const normalizedKey = normalizeMapKey(filePath);
|
|
2446
|
+
if (collected.length > 0) {
|
|
2447
|
+
this.lastKnownDiagnostics.set(normalizedKey, collected);
|
|
2448
|
+
this.lastKnownContentHash.set(normalizedKey, this.hashContent(content));
|
|
2449
|
+
}
|
|
2450
|
+
else {
|
|
2451
|
+
this.lastKnownDiagnostics.delete(normalizedKey);
|
|
2452
|
+
this.lastKnownContentHash.delete(normalizedKey);
|
|
1731
2453
|
}
|
|
1732
2454
|
}
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
// findings that just hadn't arrived yet — it must not be trusted as a
|
|
1746
|
-
// confirmed answer.
|
|
1747
|
-
const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
|
|
1748
|
-
// #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
|
|
1749
|
-
// "actually warm" signal `ensureWarmForSweep` waits for — mark every
|
|
1750
|
-
// spawned server so a later sweep in this session sees the check as a
|
|
1751
|
-
// no-op instead of paying the warm-up round trip again.
|
|
1752
|
-
//
|
|
1753
|
-
// #743: the diagnostics wait is a blanket (touch-wide) gate, but the
|
|
1754
|
-
// notify-write timeout is now PER-SERVER — a healthy server whose sibling's
|
|
1755
|
-
// write stalled must still be eligible, so only servers whose OWN write
|
|
1756
|
-
// timed out are skipped here (rather than gating the whole loop on the
|
|
1757
|
-
// file-level `inconclusive`).
|
|
1758
|
-
const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
|
|
1759
|
-
if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
|
|
1760
|
-
for (const entry of spawned) {
|
|
1761
|
-
if (notifyTimedOutServerIds.has(entry.info.id))
|
|
1762
|
-
continue;
|
|
1763
|
-
const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1764
|
-
if (key)
|
|
1765
|
-
this.markDemonstratedReadyKey(key);
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
// Prime the last-known cache WITH the hash of the content we just synced,
|
|
1769
|
-
// so a hot-path consumer (actionable-warnings at turn_end) can verify the
|
|
1770
|
-
// cached diagnostics are for the current bytes before reusing them instead
|
|
1771
|
-
// of paying for a second open+wait. Only when we actually collected — a
|
|
1772
|
-
// non-collecting touch (didChange-only) leaves the prior entry intact.
|
|
1773
|
-
// Skip this entirely when the touch was inconclusive: an unconfirmed
|
|
1774
|
-
// empty `collected` must never erase a previously-confirmed non-empty
|
|
1775
|
-
// record (that's the #570 bug — a timeout silently reporting as clean
|
|
1776
|
-
// and wiping out known-good diagnostic state).
|
|
1777
|
-
if (collected !== undefined && !inconclusive) {
|
|
1778
|
-
const normalizedKey = normalizeMapKey(filePath);
|
|
1779
|
-
if (collected.length > 0) {
|
|
1780
|
-
this.lastKnownDiagnostics.set(normalizedKey, collected);
|
|
1781
|
-
this.lastKnownContentHash.set(normalizedKey, this.hashContent(content));
|
|
2455
|
+
// #1179 (shape-5 structural fix): build the result WRAPPER. The two flags
|
|
2456
|
+
// that used to ride the returned array as NON-enumerable side-channels
|
|
2457
|
+
// (`Object.defineProperty(collected, ...)`, dropped by any `[...]`/`.filter`/
|
|
2458
|
+
// `JSON` copy — the #1094/#1096 loss class) are now EXPLICIT ENUMERABLE
|
|
2459
|
+
// fields on this wrapper. `.diags` holds the array a copy operates on, so the
|
|
2460
|
+
// flags survive by construction. Field presence mirrors the old attachment
|
|
2461
|
+
// conditions EXACTLY: `inconclusive` only for a confirmed-inconclusive
|
|
2462
|
+
// collect, `binding` only for a collecting touch — a non-collecting touch
|
|
2463
|
+
// keeps resolving `{ diags: [] }`, no flags.
|
|
2464
|
+
const result = { diags: collected ?? [] };
|
|
2465
|
+
if (collected !== undefined && inconclusive) {
|
|
2466
|
+
result.inconclusive = true;
|
|
1782
2467
|
}
|
|
1783
|
-
else {
|
|
1784
|
-
|
|
1785
|
-
|
|
2468
|
+
else if (collected !== undefined) {
|
|
2469
|
+
// Preserve the lower-level affirmative result across consumers. In
|
|
2470
|
+
// particular, the silent-clean gates above clear diagnosticsTimedOut only
|
|
2471
|
+
// after a successful notify and capability-confirmed wait; reclassifying
|
|
2472
|
+
// that empty array later would discard the evidence that made it clean.
|
|
2473
|
+
result.confirmation = "confirmed";
|
|
1786
2474
|
}
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
//
|
|
1790
|
-
//
|
|
1791
|
-
//
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
2475
|
+
// #1095: attach the merged content binding so a consumer can ask whether
|
|
2476
|
+
// these diagnostics were computed against current disk. Composed across every
|
|
2477
|
+
// spawned client so a single client whose view diverged from disk marks the
|
|
2478
|
+
// whole merged result mismatched. Disk verify is lazy + memoized per
|
|
2479
|
+
// (file, mtime).
|
|
2480
|
+
let binding;
|
|
2481
|
+
if (collected !== undefined) {
|
|
2482
|
+
binding = syncConfirmed
|
|
2483
|
+
? // #1095 (P3-b): a tsserver sync-confirmed result is authoritative for
|
|
2484
|
+
// the current buffer but not tied to the publish-path fingerprint —
|
|
2485
|
+
// surface "unknown" so a stale publish binding can't demote it.
|
|
2486
|
+
{ boundToCurrentDisk: "unknown" }
|
|
2487
|
+
: this.mergeBinding(filePath,
|
|
2488
|
+
// Optional-chain so a client without the getter (test doubles, a
|
|
2489
|
+
// partially-mocked client) yields "unknown" rather than throwing —
|
|
2490
|
+
// unknown preserves pre-#1095 behavior for that contributor.
|
|
2491
|
+
spawned.map((entry) => entry.client.getDiagnosticBinding?.(filePath)));
|
|
2492
|
+
result.binding = binding;
|
|
2493
|
+
}
|
|
2494
|
+
// The recent-touches entries are recorded per server inside the notify-write
|
|
2495
|
+
// loop above, at the moment each server's own write lands (#1253) — a server
|
|
2496
|
+
// whose write timed out or rejected deliberately gets none, so the next touch
|
|
2497
|
+
// re-pushes it rather than debouncing a failure into a later touch that looks
|
|
2498
|
+
// fully delivered. Nothing to record here: a skipped server keeps its original
|
|
2499
|
+
// entry (and timestamp) so its window still expires naturally instead of being
|
|
2500
|
+
// extended by every reuse.
|
|
2501
|
+
logLatency({
|
|
2502
|
+
type: "phase",
|
|
2503
|
+
phase: "lsp_touch_file",
|
|
2504
|
+
filePath: normalizedPath,
|
|
2505
|
+
durationMs: Date.now() - startedAt,
|
|
2506
|
+
metadata: {
|
|
2507
|
+
serverCountReady: spawned.length,
|
|
2508
|
+
clientScope,
|
|
2509
|
+
diagnosticsMode,
|
|
2510
|
+
source,
|
|
2511
|
+
failureKind: "success",
|
|
2512
|
+
collectedDiagnostics: collected?.length,
|
|
2513
|
+
// #1095: observability so an unbinding server is diagnosable —
|
|
2514
|
+
// "bound" (matches disk) / "mismatch" (diverged) / "unknown"
|
|
2515
|
+
// (version-less server or disk unreadable). Absent for non-collecting
|
|
2516
|
+
// touches (no merged result to bind).
|
|
2517
|
+
...(binding !== undefined && {
|
|
2518
|
+
bindingState: bindingStateLabel(binding.boundToCurrentDisk),
|
|
2519
|
+
}),
|
|
2520
|
+
notifySkipped,
|
|
2521
|
+
notifyWriteTimedOut,
|
|
2522
|
+
// #743: per-server detail — which servers' writes actually timed out.
|
|
2523
|
+
// Absent when none did. `notifyWriteTimedOut` is the file-level "at
|
|
2524
|
+
// least one" summary.
|
|
2525
|
+
...(notifyWriteTimedOutServerIds.length > 0 && {
|
|
2526
|
+
notifyWriteTimedOutServerIds,
|
|
2527
|
+
}),
|
|
2528
|
+
diagnosticsTimedOut,
|
|
2529
|
+
inconclusive,
|
|
2530
|
+
// R8 (#714): server ids of auxiliaries whose push wait was cut off by
|
|
2531
|
+
// the aux grace window (primary settled clean + aux timed out in grace).
|
|
2532
|
+
// Absent when no aux was cut off. These servers' diagnostics are
|
|
2533
|
+
// advisory-only and will surface on the next edit from their cache.
|
|
2534
|
+
...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
|
|
2535
|
+
},
|
|
1797
2536
|
});
|
|
2537
|
+
return result;
|
|
1798
2538
|
}
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
if (!notifySkipped) {
|
|
1803
|
-
this.markTouched(filePath, content, clientScope);
|
|
2539
|
+
finally {
|
|
2540
|
+
for (const key of leaseKeys)
|
|
2541
|
+
this.releaseClientLease(key);
|
|
1804
2542
|
}
|
|
1805
|
-
logLatency({
|
|
1806
|
-
type: "phase",
|
|
1807
|
-
phase: "lsp_touch_file",
|
|
1808
|
-
filePath: normalizedPath,
|
|
1809
|
-
durationMs: Date.now() - startedAt,
|
|
1810
|
-
metadata: {
|
|
1811
|
-
serverCountReady: spawned.length,
|
|
1812
|
-
clientScope,
|
|
1813
|
-
diagnosticsMode,
|
|
1814
|
-
source,
|
|
1815
|
-
failureKind: "success",
|
|
1816
|
-
collectedDiagnostics: collected?.length,
|
|
1817
|
-
notifySkipped,
|
|
1818
|
-
notifyWriteTimedOut,
|
|
1819
|
-
// #743: per-server detail — which servers' writes actually timed out.
|
|
1820
|
-
// Absent when none did. `notifyWriteTimedOut` is the file-level "at
|
|
1821
|
-
// least one" summary.
|
|
1822
|
-
...(notifyWriteTimedOutServerIds.length > 0 && {
|
|
1823
|
-
notifyWriteTimedOutServerIds,
|
|
1824
|
-
}),
|
|
1825
|
-
diagnosticsTimedOut,
|
|
1826
|
-
inconclusive,
|
|
1827
|
-
// R8 (#714): server ids of auxiliaries whose push wait was cut off by
|
|
1828
|
-
// the aux grace window (primary settled clean + aux timed out in grace).
|
|
1829
|
-
// Absent when no aux was cut off. These servers' diagnostics are
|
|
1830
|
-
// advisory-only and will surface on the next edit from their cache.
|
|
1831
|
-
...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
|
|
1832
|
-
},
|
|
1833
|
-
});
|
|
1834
|
-
return collected ?? [];
|
|
1835
2543
|
}
|
|
1836
2544
|
/**
|
|
1837
2545
|
* Get diagnostics for a file
|
|
@@ -2036,6 +2744,40 @@ export class LSPService {
|
|
|
2036
2744
|
hashContent(content) {
|
|
2037
2745
|
return createHash("sha256").update(content).digest("hex");
|
|
2038
2746
|
}
|
|
2747
|
+
/**
|
|
2748
|
+
* #1095: compose the content `binding` for a merged diagnostics result across
|
|
2749
|
+
* the clients that contributed to it (primary + any auxiliaries). Verifies
|
|
2750
|
+
* each contributor's stored binding against current disk lazily (memoized per
|
|
2751
|
+
* file+mtime by {@link diskBindingCache}) and composes per
|
|
2752
|
+
* {@link composeBoundToCurrentDisk}: ANY contributor mismatches disk → false;
|
|
2753
|
+
* else all "unknown" (or no contributors) → "unknown"; else true. The
|
|
2754
|
+
* surfaced version/contentHash come from the first contributor that carries
|
|
2755
|
+
* them — informational only; the load-bearing field is `boundToCurrentDisk`.
|
|
2756
|
+
* #1104: `version` and `contentHash` are tracked INDEPENDENTLY (not gated on
|
|
2757
|
+
* the same contributor carrying both) — a pull-sourced binding deliberately
|
|
2758
|
+
* has no `version` (the pull protocol has no version-echo concept) but does
|
|
2759
|
+
* carry a real `contentHash`; gating the latter on the former would silently
|
|
2760
|
+
* drop it here even though `boundToCurrentDisk` above already used it.
|
|
2761
|
+
*/
|
|
2762
|
+
mergeBinding(filePath, stored) {
|
|
2763
|
+
const verdicts = [];
|
|
2764
|
+
let version;
|
|
2765
|
+
let contentHash;
|
|
2766
|
+
for (const entry of stored) {
|
|
2767
|
+
verdicts.push(this.diskBindingCache.boundToCurrentDisk(filePath, entry ?? {}));
|
|
2768
|
+
if (version === undefined && entry?.version !== undefined) {
|
|
2769
|
+
version = entry.version;
|
|
2770
|
+
}
|
|
2771
|
+
if (contentHash === undefined && entry?.contentHash !== undefined) {
|
|
2772
|
+
contentHash = entry.contentHash;
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
return {
|
|
2776
|
+
version,
|
|
2777
|
+
contentHash,
|
|
2778
|
+
boundToCurrentDisk: composeBoundToCurrentDisk(verdicts),
|
|
2779
|
+
};
|
|
2780
|
+
}
|
|
2039
2781
|
/**
|
|
2040
2782
|
* Navigation: go to definition
|
|
2041
2783
|
*/
|
|
@@ -2134,18 +2876,18 @@ export class LSPService {
|
|
|
2134
2876
|
* advertisement in the client). If filePath is given, target that file's
|
|
2135
2877
|
* server; otherwise the first active client.
|
|
2136
2878
|
*/
|
|
2137
|
-
async executeCommand(filePath, command, args) {
|
|
2879
|
+
async executeCommand(filePath, command, args, mutationContext) {
|
|
2138
2880
|
if (filePath) {
|
|
2139
2881
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
2140
2882
|
if (!spawned) {
|
|
2141
2883
|
return { executed: false, reason: "no LSP server for file" };
|
|
2142
2884
|
}
|
|
2143
|
-
return spawned.client.executeCommand(command, args);
|
|
2885
|
+
return spawned.client.executeCommand(command, args, mutationContext);
|
|
2144
2886
|
}
|
|
2145
2887
|
const first = this.state.clients.values().next().value;
|
|
2146
2888
|
if (!first)
|
|
2147
2889
|
return { executed: false, reason: "no active LSP server" };
|
|
2148
|
-
return first.executeCommand(command, args);
|
|
2890
|
+
return first.executeCommand(command, args, mutationContext);
|
|
2149
2891
|
}
|
|
2150
2892
|
/**
|
|
2151
2893
|
* Capability snapshot for LSP operations.
|
|
@@ -2180,7 +2922,7 @@ export class LSPService {
|
|
|
2180
2922
|
if (filePath) {
|
|
2181
2923
|
const servers = getServersForFileWithConfig(filePath);
|
|
2182
2924
|
for (const server of servers) {
|
|
2183
|
-
const root = await
|
|
2925
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
2184
2926
|
if (!root)
|
|
2185
2927
|
continue;
|
|
2186
2928
|
const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
|
|
@@ -2254,6 +2996,20 @@ export class LSPService {
|
|
|
2254
2996
|
async renameFile(oldFilePath, newFilePath, options) {
|
|
2255
2997
|
const cwd = options.cwd;
|
|
2256
2998
|
const apply = options.apply ?? false;
|
|
2999
|
+
// Validate the complete resource operation before asking any server for
|
|
3000
|
+
// willRenameFiles edits. This is a read-only preflight, but it reuses the
|
|
3001
|
+
// same confinement, realpath/symlink, existence, and destination checks as
|
|
3002
|
+
// the eventual apply path, so an invalid rename cannot first mutate an
|
|
3003
|
+
// in-workspace file through returned text edits (including previews).
|
|
3004
|
+
await validateWorkspaceEdit({
|
|
3005
|
+
documentChanges: [
|
|
3006
|
+
{
|
|
3007
|
+
kind: "rename",
|
|
3008
|
+
oldUri: pathToFileURL(oldFilePath).href,
|
|
3009
|
+
newUri: pathToFileURL(newFilePath).href,
|
|
3010
|
+
},
|
|
3011
|
+
],
|
|
3012
|
+
}, cwd);
|
|
2257
3013
|
const priorityServerIds = getServersForFileWithConfig(oldFilePath).map((server) => server.id);
|
|
2258
3014
|
const activeClients = this.activeClientsForCwd(cwd, priorityServerIds);
|
|
2259
3015
|
const willRenameFailures = [];
|
|
@@ -2290,25 +3046,142 @@ export class LSPService {
|
|
|
2290
3046
|
summary,
|
|
2291
3047
|
};
|
|
2292
3048
|
}
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
3049
|
+
let applied;
|
|
3050
|
+
try {
|
|
3051
|
+
applied = await applyWorkspaceEdit(merged.edit, cwd, {
|
|
3052
|
+
mutationContext: options.mutationContext,
|
|
3053
|
+
observe: false,
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
3056
|
+
catch (err) {
|
|
3057
|
+
if (options.mutationContext) {
|
|
3058
|
+
const partial = err
|
|
3059
|
+
.appliedWorkspaceEdit;
|
|
3060
|
+
if (partial) {
|
|
3061
|
+
recordLspMutation(options.mutationContext, {
|
|
3062
|
+
results: [partial],
|
|
3063
|
+
status: "failed",
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
throw err;
|
|
3068
|
+
}
|
|
3069
|
+
const openDocuments = activeClients
|
|
3070
|
+
.filter(({ client }) => client.isDocumentOpen(oldFilePath))
|
|
3071
|
+
.map(({ serverId, client }) => ({
|
|
3072
|
+
serverId,
|
|
3073
|
+
client,
|
|
3074
|
+
oldUri: client.getDocumentUri(oldFilePath),
|
|
3075
|
+
}));
|
|
3076
|
+
const closeFailures = [];
|
|
3077
|
+
await Promise.all(openDocuments.map(async ({ serverId, client }) => {
|
|
3078
|
+
try {
|
|
3079
|
+
await client.closeDocument(oldFilePath);
|
|
3080
|
+
return undefined;
|
|
3081
|
+
}
|
|
3082
|
+
catch (err) {
|
|
3083
|
+
closeFailures.push({
|
|
3084
|
+
serverId,
|
|
3085
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3086
|
+
});
|
|
3087
|
+
return undefined;
|
|
3088
|
+
}
|
|
3089
|
+
}));
|
|
3090
|
+
if (closeFailures.length > 0) {
|
|
3091
|
+
if (options.mutationContext) {
|
|
3092
|
+
recordLspMutation(options.mutationContext, {
|
|
3093
|
+
results: [applied],
|
|
3094
|
+
status: "failed",
|
|
3095
|
+
});
|
|
3096
|
+
}
|
|
3097
|
+
// Do not rename or send didRenameFiles while any server still has the
|
|
3098
|
+
// old document open. Re-open/resync every affected client so a partial
|
|
3099
|
+
// close cannot leave an in-memory document behind the disk contents.
|
|
3100
|
+
// Reopen with the document's ACTUAL language ID (the same resolver every
|
|
3101
|
+
// genuine open uses) rather than a hardcoded "plaintext" fallback — a
|
|
3102
|
+
// wrong languageId here degrades that server's diagnostics until the
|
|
3103
|
+
// next genuine open (#1147 P3-7).
|
|
3104
|
+
const content = await fs.readFile(oldFilePath, "utf-8");
|
|
3105
|
+
const languageId = getLanguageId(oldFilePath) ?? "plaintext";
|
|
3106
|
+
await Promise.all(openDocuments.map(({ client }) => client.notify.open(oldFilePath, content, languageId, true, true)));
|
|
3107
|
+
throw new Error(`workspace/didClose failed; rename aborted: ${closeFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
|
|
3108
|
+
}
|
|
3109
|
+
let renameApplied;
|
|
3110
|
+
try {
|
|
3111
|
+
// Route the resource mutation through the same preflight confinement,
|
|
3112
|
+
// realpath and symlink policy as all other workspace edits. Do not
|
|
3113
|
+
// duplicate that security boundary with a direct mkdir/rename pair.
|
|
3114
|
+
renameApplied = await applyWorkspaceEdit({
|
|
3115
|
+
documentChanges: [
|
|
3116
|
+
{
|
|
3117
|
+
kind: "rename",
|
|
3118
|
+
oldUri: pathToFileURL(oldFilePath).href,
|
|
3119
|
+
newUri: pathToFileURL(newFilePath).href,
|
|
3120
|
+
},
|
|
3121
|
+
],
|
|
3122
|
+
}, cwd, { observe: false });
|
|
3123
|
+
}
|
|
3124
|
+
catch (err) {
|
|
3125
|
+
if (options.mutationContext) {
|
|
3126
|
+
recordLspMutation(options.mutationContext, {
|
|
3127
|
+
results: [applied],
|
|
3128
|
+
status: "failed",
|
|
3129
|
+
});
|
|
3130
|
+
}
|
|
3131
|
+
throw err;
|
|
3132
|
+
}
|
|
2296
3133
|
const relOld = path.relative(cwd, oldFilePath).replace(/\\/g, "/") ||
|
|
2297
3134
|
path.basename(oldFilePath);
|
|
2298
3135
|
const relNew = path.relative(cwd, newFilePath).replace(/\\/g, "/") ||
|
|
2299
3136
|
path.basename(newFilePath);
|
|
2300
3137
|
const renameDescription = `Renamed ${relOld} → ${relNew}`;
|
|
2301
3138
|
await Promise.all(activeClients.map(async ({ serverId, client }) => {
|
|
3139
|
+
const opened = openDocuments.find((entry) => entry.serverId === serverId);
|
|
2302
3140
|
try {
|
|
2303
|
-
|
|
3141
|
+
if (opened?.oldUri) {
|
|
3142
|
+
await client.didRenameFiles(oldFilePath, newFilePath, opened.oldUri, destinationUriPreservingSpelling(opened.oldUri, oldFilePath, newFilePath));
|
|
3143
|
+
}
|
|
3144
|
+
else {
|
|
3145
|
+
await client.didRenameFiles(oldFilePath, newFilePath);
|
|
3146
|
+
}
|
|
3147
|
+
return undefined;
|
|
2304
3148
|
}
|
|
2305
3149
|
catch (err) {
|
|
2306
3150
|
didRenameFailures.push({
|
|
2307
3151
|
serverId,
|
|
2308
3152
|
error: err instanceof Error ? err.message : String(err),
|
|
2309
3153
|
});
|
|
3154
|
+
return undefined;
|
|
2310
3155
|
}
|
|
2311
3156
|
}));
|
|
3157
|
+
const files = [...new Set([...applied.files, oldFilePath, newFilePath])];
|
|
3158
|
+
if (options.mutationContext) {
|
|
3159
|
+
recordLspMutation(options.mutationContext, {
|
|
3160
|
+
results: [
|
|
3161
|
+
{
|
|
3162
|
+
...applied,
|
|
3163
|
+
files,
|
|
3164
|
+
operationTotal: applied.operationTotal + renameApplied.operationTotal,
|
|
3165
|
+
appliedOperationTotal: applied.appliedOperationTotal + renameApplied.appliedOperationTotal,
|
|
3166
|
+
appliedOperationIndexes: [
|
|
3167
|
+
...applied.appliedOperationIndexes,
|
|
3168
|
+
...renameApplied.appliedOperationIndexes.map((index) => applied.operationTotal + index),
|
|
3169
|
+
],
|
|
3170
|
+
operationCounts: {
|
|
3171
|
+
textEdits: applied.operationCounts.textEdits + renameApplied.operationCounts.textEdits,
|
|
3172
|
+
create: applied.operationCounts.create + renameApplied.operationCounts.create,
|
|
3173
|
+
rename: applied.operationCounts.rename + renameApplied.operationCounts.rename,
|
|
3174
|
+
delete: applied.operationCounts.delete + renameApplied.operationCounts.delete,
|
|
3175
|
+
},
|
|
3176
|
+
fileDetails: [
|
|
3177
|
+
...applied.fileDetails,
|
|
3178
|
+
...renameApplied.fileDetails,
|
|
3179
|
+
],
|
|
3180
|
+
},
|
|
3181
|
+
],
|
|
3182
|
+
status: "success",
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
2312
3185
|
return {
|
|
2313
3186
|
applied: true,
|
|
2314
3187
|
serverIds: activeClients.map((entry) => entry.serverId),
|
|
@@ -2318,7 +3191,7 @@ export class LSPService {
|
|
|
2318
3191
|
inputEditCount: merged.inputEditCount,
|
|
2319
3192
|
summary,
|
|
2320
3193
|
descriptions: [...applied.descriptions, renameDescription],
|
|
2321
|
-
files
|
|
3194
|
+
files,
|
|
2322
3195
|
};
|
|
2323
3196
|
}
|
|
2324
3197
|
/**
|
|
@@ -2556,16 +3429,21 @@ export class LSPService {
|
|
|
2556
3429
|
ms: timeoutMs,
|
|
2557
3430
|
onTimeout: "undefined",
|
|
2558
3431
|
}));
|
|
3432
|
+
const coldServerIds = stillColdServerIds();
|
|
2559
3433
|
logLatency({
|
|
2560
3434
|
type: "phase",
|
|
2561
|
-
phase:
|
|
3435
|
+
phase: options.signal?.aborted
|
|
3436
|
+
? "lsp_sweep_warmup_aborted"
|
|
3437
|
+
: coldServerIds.length > 0
|
|
3438
|
+
? "lsp_sweep_warmup_failed"
|
|
3439
|
+
: "lsp_sweep_warmup_done",
|
|
2562
3440
|
filePath: representativeFile,
|
|
2563
3441
|
durationMs: Date.now() - startedAt,
|
|
2564
3442
|
metadata: {
|
|
2565
3443
|
serverIds: servers.map((s) => s.id),
|
|
2566
3444
|
timeoutMs,
|
|
2567
3445
|
attempt,
|
|
2568
|
-
coldServerIds
|
|
3446
|
+
coldServerIds,
|
|
2569
3447
|
},
|
|
2570
3448
|
});
|
|
2571
3449
|
};
|
|
@@ -2591,13 +3469,6 @@ export class LSPService {
|
|
|
2591
3469
|
}
|
|
2592
3470
|
}
|
|
2593
3471
|
if (failedServerIds.length > 0) {
|
|
2594
|
-
logLatency({
|
|
2595
|
-
type: "phase",
|
|
2596
|
-
phase: "lsp_sweep_warmup_failed",
|
|
2597
|
-
filePath: representativeFile,
|
|
2598
|
-
durationMs: 0,
|
|
2599
|
-
metadata: { failedServerIds, timeoutMs },
|
|
2600
|
-
});
|
|
2601
3472
|
// #799: record the negative cache so a LATER sweep this session
|
|
2602
3473
|
// skips straight past re-paying this warm-up (initial + retry).
|
|
2603
3474
|
// Cleared automatically the moment the server demonstrates
|
|
@@ -2659,13 +3530,31 @@ export class LSPService {
|
|
|
2659
3530
|
]);
|
|
2660
3531
|
const cachedResults = [];
|
|
2661
3532
|
const filesToTouch = [];
|
|
3533
|
+
const writeIndexByPath = new Map();
|
|
2662
3534
|
for (const filePath of files) {
|
|
3535
|
+
// Reserve order before cache lookup/touch admission. A result that settles
|
|
3536
|
+
// later is still ordered by when this scan began observing the file (#1198).
|
|
3537
|
+
writeIndexByPath.set(normalizeMapKey(filePath), options.nextWriteIndex?.());
|
|
2663
3538
|
const cached = workspaceDiagnosticsCacheCtx.lookup(filePath, workspaceSweepScopeKey);
|
|
2664
|
-
|
|
3539
|
+
// #1095 (P2-1 bug-class sweep): both sweeps share cache entries under the
|
|
3540
|
+
// same scopeKey, so this service sweep must apply the SAME content-binding
|
|
3541
|
+
// gate the tools/lsp-diagnostics.ts sibling site does — an entry whose
|
|
3542
|
+
// recorded fingerprint no longer matches disk (bytes changed WITHOUT an
|
|
3543
|
+
// mtime bump the freshness check would catch, exactly what contentHash
|
|
3544
|
+
// exists to detect) must NOT be replayed and reconciled as confirmed via
|
|
3545
|
+
// mode=full. On a mismatch, fall through to a fresh touch.
|
|
3546
|
+
if (cached && cached.binding.boundToCurrentDisk !== false) {
|
|
2665
3547
|
cachedResults.push({
|
|
2666
3548
|
filePath,
|
|
2667
3549
|
diagnostics: cached.diagnostics,
|
|
2668
3550
|
count: cached.count,
|
|
3551
|
+
// #1093: a cache hit replays an older observation — carry its
|
|
3552
|
+
// scan time so mode=full's footer reconcile stamps `touchedAt`
|
|
3553
|
+
// with when the truth was seen, not now().
|
|
3554
|
+
observedAt: cached.scannedAt,
|
|
3555
|
+
contentHash: cached.binding.contentHash,
|
|
3556
|
+
boundToCurrentDisk: cached.binding.boundToCurrentDisk,
|
|
3557
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
2669
3558
|
});
|
|
2670
3559
|
}
|
|
2671
3560
|
else {
|
|
@@ -2847,8 +3736,16 @@ export class LSPService {
|
|
|
2847
3736
|
if (attached && !attached.available) {
|
|
2848
3737
|
await this.ensureWarmForSweep(filePath, { signal });
|
|
2849
3738
|
}
|
|
2850
|
-
|
|
2851
|
-
|
|
3739
|
+
// #1179 (shape-5 structural fix): normalize both sources into the
|
|
3740
|
+
// `TouchFileResult` wrapper shape so the flag reads below come off
|
|
3741
|
+
// EXPLICIT fields, not a non-enumerable side-channel a copy would drop.
|
|
3742
|
+
// The warm-attach IPC branch resolves a plain diagnostics array with
|
|
3743
|
+
// `inconclusive` re-surfaced as an enumerable DTO field (the socket can
|
|
3744
|
+
// carry no binding, and the IPC client already rejects an inconclusive
|
|
3745
|
+
// answer, so `available` implies confirmed) — wrap it as `{ diags }`;
|
|
3746
|
+
// the incumbent branch already returns the wrapper.
|
|
3747
|
+
const touchResult = attached?.available
|
|
3748
|
+
? { diags: attached.response.diagnostics }
|
|
2852
3749
|
: await withDeadline(this.touchFile(filePath, content, {
|
|
2853
3750
|
diagnostics: "document",
|
|
2854
3751
|
collectDiagnostics: true,
|
|
@@ -2863,17 +3760,23 @@ export class LSPService {
|
|
|
2863
3760
|
// full wait budget only once across this whole sweep.
|
|
2864
3761
|
sweepIndexGate,
|
|
2865
3762
|
}), { ms: perFileMs, onTimeout: "undefined" });
|
|
3763
|
+
const diagnostics = touchResult?.diags;
|
|
2866
3764
|
// #571: prefer #570's real per-touch inconclusive signal
|
|
2867
|
-
// (`touchFile`'s
|
|
2868
|
-
//
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
|
|
2872
|
-
const
|
|
2873
|
-
?.inconclusive === true;
|
|
2874
|
-
const timedOut = diagnostics === undefined || inconclusive;
|
|
3765
|
+
// (`touchFile`'s `.inconclusive` flag — set when the notify write or the
|
|
3766
|
+
// diagnostics wait itself timed out) over this sweep's own OUTER
|
|
3767
|
+
// `perFileMs` deadline, which only catches a touch that never returned at
|
|
3768
|
+
// all within budget. Either one means the result wasn't confirmed.
|
|
3769
|
+
const inconclusive = touchResult?.inconclusive === true;
|
|
3770
|
+
const timedOut = touchResult === undefined || inconclusive;
|
|
2875
3771
|
if (timedOut)
|
|
2876
3772
|
timedOutFiles += 1;
|
|
3773
|
+
// #1104 (shape 5 — AGENTS.md): the touch's content binding is an
|
|
3774
|
+
// EXPLICIT enumerable field on the wrapper now (#1179), so it survives
|
|
3775
|
+
// `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
|
|
3776
|
+
// — read it off `touchResult`, not off the derived array. A version-
|
|
3777
|
+
// less/pull-less server composed no binding, so `contentHash` stays
|
|
3778
|
+
// undefined here (honest "unknown" downstream), matching prior behavior.
|
|
3779
|
+
const rawBinding = touchResult?.binding;
|
|
2877
3780
|
// #586: honor each auxiliary profile's native inline-suppression
|
|
2878
3781
|
// comment (e.g. opengrep's `// nosemgrep`, #441) — computed from the
|
|
2879
3782
|
// raw `diagnostics` (before this drops its non-enumerable
|
|
@@ -2897,6 +3800,9 @@ export class LSPService {
|
|
|
2897
3800
|
diagnostics: filteredDiagnostics ?? [],
|
|
2898
3801
|
count: filteredDiagnostics?.length ?? 0,
|
|
2899
3802
|
timedOut,
|
|
3803
|
+
contentHash: rawBinding?.contentHash,
|
|
3804
|
+
boundToCurrentDisk: rawBinding?.boundToCurrentDisk,
|
|
3805
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
2900
3806
|
});
|
|
2901
3807
|
}
|
|
2902
3808
|
catch (err) {
|
|
@@ -2909,6 +3815,7 @@ export class LSPService {
|
|
|
2909
3815
|
// no confirmed result was obtained, so reconciliation (#571) must
|
|
2910
3816
|
// skip it the same way.
|
|
2911
3817
|
timedOut: true,
|
|
3818
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
2912
3819
|
});
|
|
2913
3820
|
}
|
|
2914
3821
|
completed += 1;
|
|
@@ -2948,7 +3855,10 @@ export class LSPService {
|
|
|
2948
3855
|
const pulled = await this.tryWorkspacePull(group.files, perFileMs);
|
|
2949
3856
|
if (pulled) {
|
|
2950
3857
|
for (const result of pulled) {
|
|
2951
|
-
results.push(
|
|
3858
|
+
results.push({
|
|
3859
|
+
...result,
|
|
3860
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
|
|
3861
|
+
});
|
|
2952
3862
|
// #671: a pull result is always confirmed (see
|
|
2953
3863
|
// `tryWorkspacePull`'s doc comment), so it's cache-eligible
|
|
2954
3864
|
// too — best-effort stat since the pull already resolved the
|
|
@@ -3072,7 +3982,15 @@ export class LSPService {
|
|
|
3072
3982
|
const scannedAt = scannedMtimeByFile.get(result.filePath);
|
|
3073
3983
|
if (result.error || result.timedOut || scannedAt === undefined)
|
|
3074
3984
|
continue;
|
|
3075
|
-
|
|
3985
|
+
// #1104: thread the per-result `contentHash` (from either the
|
|
3986
|
+
// `tryWorkspacePull` fast path or a per-file touch's own #1095 binding)
|
|
3987
|
+
// into the cache entry — previously this call never passed one, so
|
|
3988
|
+
// every pull-served entry read binding "unknown" forever and the #1095
|
|
3989
|
+
// P2-1 service-sweep binding gate above (`cached.binding.
|
|
3990
|
+
// boundToCurrentDisk !== false`) could never demote a pull-cached
|
|
3991
|
+
// entry. Absent `contentHash` (no binding was available) behaves
|
|
3992
|
+
// exactly as before.
|
|
3993
|
+
workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
|
|
3076
3994
|
}
|
|
3077
3995
|
workspaceDiagnosticsCacheCtx.persist();
|
|
3078
3996
|
return [...cachedResults, ...results].filter(Boolean);
|
|
@@ -3099,14 +4017,24 @@ export class LSPService {
|
|
|
3099
4017
|
return undefined;
|
|
3100
4018
|
const byPath = new Map();
|
|
3101
4019
|
for (const entry of report) {
|
|
3102
|
-
byPath.set(normalizeMapKey(entry.filePath), entry
|
|
4020
|
+
byPath.set(normalizeMapKey(entry.filePath), entry);
|
|
3103
4021
|
}
|
|
3104
4022
|
return groupFiles.map((filePath) => {
|
|
3105
|
-
const
|
|
4023
|
+
const entry = byPath.get(normalizeMapKey(filePath));
|
|
4024
|
+
const diagnostics = entry?.diagnostics ?? [];
|
|
3106
4025
|
// A pull that got here returned a real workspace/diagnostic report
|
|
3107
4026
|
// (see the `!report` guard above) — always confirmed, unlike a
|
|
3108
4027
|
// per-file touchFile default-empty on timeout.
|
|
3109
|
-
|
|
4028
|
+
// #1104: `contentHash` comes straight from the client's pull layer —
|
|
4029
|
+
// a file absent from the report (reported "clean" here) carries none,
|
|
4030
|
+
// same honest "unknown" the record site already applies for it.
|
|
4031
|
+
return {
|
|
4032
|
+
filePath,
|
|
4033
|
+
diagnostics,
|
|
4034
|
+
count: diagnostics.length,
|
|
4035
|
+
timedOut: false,
|
|
4036
|
+
contentHash: entry?.contentHash,
|
|
4037
|
+
};
|
|
3110
4038
|
});
|
|
3111
4039
|
}
|
|
3112
4040
|
catch {
|
|
@@ -3118,6 +4046,9 @@ export class LSPService {
|
|
|
3118
4046
|
*/
|
|
3119
4047
|
async getAllDiagnostics() {
|
|
3120
4048
|
const all = new Map();
|
|
4049
|
+
// #1095: per-file stored bindings across every contributing client, merged
|
|
4050
|
+
// into one `boundToCurrentDisk` verdict after the client loop.
|
|
4051
|
+
const bindingsByPath = new Map();
|
|
3121
4052
|
const now = Date.now();
|
|
3122
4053
|
for (const [_key, client] of this.state.clients) {
|
|
3123
4054
|
// Resolve existence asynchronously (was a blocking existsSync per tracked
|
|
@@ -3150,8 +4081,35 @@ export class LSPService {
|
|
|
3150
4081
|
else {
|
|
3151
4082
|
all.set(filePath, { diags: [...entry.diags], ts: entry.ts });
|
|
3152
4083
|
}
|
|
4084
|
+
const list = bindingsByPath.get(filePath) ?? [];
|
|
4085
|
+
list.push(entry.binding);
|
|
4086
|
+
bindingsByPath.set(filePath, list);
|
|
3153
4087
|
}
|
|
3154
4088
|
}
|
|
4089
|
+
// #1095 (P2-2): expose the composed content binding per file LAZILY — the
|
|
4090
|
+
// disk stat+hash verify only runs when a consumer actually reads `.binding`.
|
|
4091
|
+
// The current caller (the cascade, once per edit turn) does NOT read it yet
|
|
4092
|
+
// (binding adoption in cascade/integration.ts is deferred to the second PR),
|
|
4093
|
+
// so this leaves ZERO eager disk cost while still surfacing the field for the
|
|
4094
|
+
// consumer that arrives next. Non-enumerable so incidental spread/serialize
|
|
4095
|
+
// (e.g. logging) can't trigger a stat storm; memoized per entry so repeated
|
|
4096
|
+
// reads verify once.
|
|
4097
|
+
// #1179: DELIBERATELY NOT migrated to an enumerable wrapper field (unlike the
|
|
4098
|
+
// `touchFile` flags). Enumerable would make an incidental `{...entry}`/serialize
|
|
4099
|
+
// eagerly fire this getter and stat every file — the exact stat storm the
|
|
4100
|
+
// laziness above exists to prevent. It therefore stays on the read-off-original
|
|
4101
|
+
// carriage contract (see `DiagnosticBinding`): every consumer reads `.binding`
|
|
4102
|
+
// off the ORIGINAL Map entry (`clients/dispatch/integration.ts` reads it off
|
|
4103
|
+
// `entry`, never a copy), and the cascade's TTL gate reads it only when fresh.
|
|
4104
|
+
for (const [filePath, entry] of all) {
|
|
4105
|
+
const stored = bindingsByPath.get(filePath) ?? [];
|
|
4106
|
+
let memo;
|
|
4107
|
+
Object.defineProperty(entry, "binding", {
|
|
4108
|
+
enumerable: false,
|
|
4109
|
+
configurable: true,
|
|
4110
|
+
get: () => (memo ??= this.mergeBinding(filePath, stored)),
|
|
4111
|
+
});
|
|
4112
|
+
}
|
|
3155
4113
|
return all;
|
|
3156
4114
|
}
|
|
3157
4115
|
/**
|
|
@@ -3186,6 +4144,9 @@ export class LSPService {
|
|
|
3186
4144
|
if (this.checkDestroyed())
|
|
3187
4145
|
return;
|
|
3188
4146
|
this.isDestroyed = true;
|
|
4147
|
+
for (const key of this.typeScriptIdleTimers.keys()) {
|
|
4148
|
+
this.clearTypeScriptIdleTimer(key);
|
|
4149
|
+
}
|
|
3189
4150
|
// Belt-and-braces: wait for any in-flight spawns so that Guard 1/2 in
|
|
3190
4151
|
// spawnClient can observe isDestroyed and clean up. Skip on the
|
|
3191
4152
|
// process-exiting path — the event loop is closing and we must not block.
|
|
@@ -3237,12 +4198,69 @@ export class LSPService {
|
|
|
3237
4198
|
getStatus() {
|
|
3238
4199
|
return Array.from(this.state.clients.entries()).map(([key, client]) => {
|
|
3239
4200
|
const [serverId, root] = key.split(":");
|
|
3240
|
-
return {
|
|
4201
|
+
return {
|
|
4202
|
+
serverId,
|
|
4203
|
+
root,
|
|
4204
|
+
connected: client.isAlive(),
|
|
4205
|
+
pullFailureHistory: (client.getPullFailureHistory?.() ?? []).map((entry) => ({
|
|
4206
|
+
...entry,
|
|
4207
|
+
message: entry.message.slice(0, 200),
|
|
4208
|
+
})),
|
|
4209
|
+
};
|
|
3241
4210
|
});
|
|
3242
4211
|
}
|
|
4212
|
+
/**
|
|
4213
|
+
* #1142 windowed-rate breaker (composes with the {@link runtimeExitCounts}
|
|
4214
|
+
* fast path — see that field and {@link RUNTIME_EXIT_WINDOW_MS}).
|
|
4215
|
+
*
|
|
4216
|
+
* Records one non-intentional death and returns true iff this death pushes
|
|
4217
|
+
* the rolling window to {@link RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths, i.e.
|
|
4218
|
+
* the breaker should give up now. The caller has ALREADY excluded intentional
|
|
4219
|
+
* teardowns (shutdown()-driven restarts, #743 eviction, session resets,
|
|
4220
|
+
* generation handoffs) — only genuine crash-respawns reach here.
|
|
4221
|
+
*
|
|
4222
|
+
* Sleep-gap / long-healthy-run guard: a death whose measured lifetime exceeds
|
|
4223
|
+
* {@link RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS} (or is unknown) is NOT
|
|
4224
|
+
* recorded — a lifetime spanning a Modern-Standby suspend, or a genuinely
|
|
4225
|
+
* long run that crashed once, is not churn. See that constant for the
|
|
4226
|
+
* per-KEY structural defense this backs up.
|
|
4227
|
+
*
|
|
4228
|
+
* Bounded on both axes (defect shape 9): prune entries outside the window,
|
|
4229
|
+
* then hard-cap the retained array at the trip count (drop-oldest — a rate
|
|
4230
|
+
* trip needs only the in-window count, so earliest-evidence order is
|
|
4231
|
+
* irrelevant here).
|
|
4232
|
+
*/
|
|
4233
|
+
recordRuntimeExitWindow(key, exitedAt, uptimeMs) {
|
|
4234
|
+
if (exitedAt == null ||
|
|
4235
|
+
uptimeMs == null ||
|
|
4236
|
+
uptimeMs > RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS) {
|
|
4237
|
+
return false;
|
|
4238
|
+
}
|
|
4239
|
+
const deaths = this.runtimeExitWindow.get(key) ?? [];
|
|
4240
|
+
deaths.push(exitedAt);
|
|
4241
|
+
// Roll the window around the most recent death. `exitedAt` is the client's
|
|
4242
|
+
// own recorded death time (#1127), which can arrive out of DETECTION order
|
|
4243
|
+
// — a death detected hours later still carries its true, older timestamp —
|
|
4244
|
+
// so anchor on the max, not on push order.
|
|
4245
|
+
const windowRef = Math.max(...deaths);
|
|
4246
|
+
const windowStart = windowRef - RUNTIME_EXIT_WINDOW_MS;
|
|
4247
|
+
let pruned = deaths.filter((t) => t >= windowStart);
|
|
4248
|
+
if (pruned.length > RUNTIME_EXIT_WINDOW_TRIP_COUNT) {
|
|
4249
|
+
pruned = pruned.slice(pruned.length - RUNTIME_EXIT_WINDOW_TRIP_COUNT);
|
|
4250
|
+
}
|
|
4251
|
+
this.runtimeExitWindow.set(key, pruned);
|
|
4252
|
+
return pruned.length >= RUNTIME_EXIT_WINDOW_TRIP_COUNT;
|
|
4253
|
+
}
|
|
3243
4254
|
/**
|
|
3244
4255
|
* Read-only circuit-breaker status, including server/root pairs that have no
|
|
3245
4256
|
* live client and would therefore be absent from getStatus().
|
|
4257
|
+
*
|
|
4258
|
+
* #1127: `failureCounts` (spawn/init failures) and `runtimeExitCounts`
|
|
4259
|
+
* (early post-init runtime exits) are two INDEPENDENT streams feeding one
|
|
4260
|
+
* circuit — either can trip its own cooldown/permanent-disable on its own
|
|
4261
|
+
* threshold, without the other. `failures` below is their SUM, purely for
|
|
4262
|
+
* display: it is the total churn behind whatever cooldown/permanent state
|
|
4263
|
+
* is showing, not a claim that both streams are at that count.
|
|
3246
4264
|
*/
|
|
3247
4265
|
getBrokenStatus() {
|
|
3248
4266
|
const keys = new Set([
|
|
@@ -3254,7 +4272,16 @@ export class LSPService {
|
|
|
3254
4272
|
return {
|
|
3255
4273
|
serverId: separator >= 0 ? key.slice(0, separator) : key,
|
|
3256
4274
|
root: separator >= 0 ? key.slice(separator + 1) : "",
|
|
3257
|
-
|
|
4275
|
+
// #1127/#1142: spawn/init failures, consecutive early post-init
|
|
4276
|
+
// exits, and the windowed-rate stream all feed the same breaker
|
|
4277
|
+
// (see `runtimeExitCounts`/`runtimeExitWindow` docs). Sum spawn/init
|
|
4278
|
+
// failures with the LARGER of the two runtime-exit streams — a
|
|
4279
|
+
// single <60s death feeds BOTH runtimeExitCounts and the window, so
|
|
4280
|
+
// `max` avoids double-counting them, while still surfacing a
|
|
4281
|
+
// windowed-only trip (whose runtimeExitCounts may be 0) instead of
|
|
4282
|
+
// misreporting `failures: 0` behind a permanent-disable.
|
|
4283
|
+
failures: (this.failureCounts.get(key) ?? 0) +
|
|
4284
|
+
Math.max(this.runtimeExitCounts.get(key) ?? 0, this.runtimeExitWindow.get(key)?.length ?? 0),
|
|
3258
4285
|
permanentlyBroken: this.permanentlyBroken.has(key),
|
|
3259
4286
|
cooldownUntil: this.state.broken.get(key) ?? 0,
|
|
3260
4287
|
};
|