@herbertgao/pi-extensions 2026.8.4 → 2026.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +11 -3
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +11 -3
- 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/feature/context.ts +74 -5
- 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 +53 -14
- 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 +18 -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 +20 -4
- 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/fleet-list.ts +15 -6
- 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/@herbertgao/pi-subagents/src/worktree.ts +9 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -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 +32 -24
- package/node_modules/@narumitw/pi-btw/package.json +6 -6
- package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
- package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -13
- package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
- package/node_modules/pi-lens/CHANGELOG.md +2535 -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/advisory-provenance.js +314 -0
- package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
- package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
- 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 +136 -24
- 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-events-logger.js +62 -6
- package/node_modules/pi-lens/dist/clients/bus-publish.js +19 -4
- 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/cascade-format.js +57 -2
- 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 +26 -0
- package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +137 -31
- 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 +361 -77
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +65 -13
- package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +18 -4
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +176 -29
- 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 +1172 -683
- 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 +47 -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/availability-policy.js +226 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
- 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 +473 -42
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -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 +18 -4
- package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
- package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
- package/node_modules/pi-lens/dist/clients/extension-log.js +432 -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/fix-worklog.js +5 -1
- package/node_modules/pi-lens/dist/clients/format-events-publish.js +51 -7
- 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 +678 -11
- package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
- package/node_modules/pi-lens/dist/clients/go-client.js +23 -40
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +124 -7
- 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 +395 -100
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +27 -55
- package/node_modules/pi-lens/dist/clients/knip-client.js +58 -42
- 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 +72 -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 +63 -9
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
- package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
- package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +73 -0
- package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +59 -14
- package/node_modules/pi-lens/dist/clients/lsp/client.js +662 -56
- package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +115 -0
- package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
- package/node_modules/pi-lens/dist/clients/lsp/index.js +2025 -800
- 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 +437 -86
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
- 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 +127 -32
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
- package/node_modules/pi-lens/dist/clients/mcp/session.js +235 -14
- 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/model-provider.js +53 -0
- 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 +76 -15
- 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/fresh-fetch.js +1 -1
- 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/runner-adapters/runner-findings.js +23 -2
- 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/query.js +24 -0
- 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/run-duration.js +55 -0
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +257 -77
- package/node_modules/pi-lens/dist/clients/runtime-context.js +129 -12
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +242 -26
- package/node_modules/pi-lens/dist/clients/runtime-session.js +428 -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 +342 -33
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +619 -93
- package/node_modules/pi-lens/dist/clients/rust-client.js +23 -38
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +90 -6
- package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
- package/node_modules/pi-lens/dist/clients/sg-runner.js +424 -196
- package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +231 -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 +7 -1
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +469 -32
- package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
- package/node_modules/pi-lens/dist/clients/tool-policy.js +390 -39
- package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -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 +50 -13
- package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
- package/node_modules/pi-lens/dist/clients/word-index.js +680 -81
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
- package/node_modules/pi-lens/dist/index.js +51785 -36118
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
- package/node_modules/pi-lens/dist/mcp/server.js +219 -46
- package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
- package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
- package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
- package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +203 -38
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +210 -42
- 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/dist/tools/symbol-search.js +1 -1
- package/node_modules/pi-lens/docs/agent-guide.md +40 -15
- 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 +14 -3
- 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 +45 -3
- package/node_modules/pi-lens/docs/globalconfig.md +56 -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 +18 -1
- package/node_modules/pi-lens/docs/tools.md +4 -1
- package/node_modules/pi-lens/docs/usage.md +41 -5
- package/node_modules/pi-lens/docs/word-index.md +35 -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-chained-type-assertions-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-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-reflect-apply-js-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-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-chained-type-assertions.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-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-reflect-apply-js.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -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 +134 -2
- package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
- package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
- package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
- package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +38 -0
- package/node_modules/pi-mcp-adapter/README.md +38 -4
- package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
- package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
- package/node_modules/pi-mcp-adapter/cli.js +25 -4
- package/node_modules/pi-mcp-adapter/commands.ts +15 -7
- package/node_modules/pi-mcp-adapter/config.ts +4 -4
- package/node_modules/pi-mcp-adapter/direct-tools.ts +58 -8
- 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/mcp-setup-panel.ts +2 -1
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +37 -4
- package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
- package/node_modules/pi-mcp-adapter/package.json +4 -3
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
- package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
- package/node_modules/pi-mcp-adapter/server-manager.ts +41 -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 +62 -4
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
- package/node_modules/pi-mcp-adapter/types.ts +176 -27
- package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
- package/node_modules/pi-web-access/CHANGELOG.md +14 -0
- package/node_modules/pi-web-access/README.md +18 -12
- package/node_modules/pi-web-access/auth-fetch.ts +148 -0
- package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
- package/node_modules/pi-web-access/curator-page.ts +5 -3
- package/node_modules/pi-web-access/curator-server.ts +2 -1
- package/node_modules/pi-web-access/extract.ts +106 -34
- package/node_modules/pi-web-access/fetch-params.ts +17 -3
- package/node_modules/pi-web-access/firecrawl.ts +172 -12
- package/node_modules/pi-web-access/gemini-search.ts +18 -4
- package/node_modules/pi-web-access/index.ts +120 -48
- package/node_modules/pi-web-access/package.json +2 -2
- package/node_modules/pi-web-access/summary-review.ts +11 -5
- package/node_modules/pi-web-access/youtube-extract.ts +2 -2
- package/package.json +12 -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
|
-
import { isAtOrAboveHomeDir, normalizeMapKey, uriToPath, } from "../path-utils.js";
|
|
25
|
+
import { isAtOrAboveHomeDir, isWindowsPath, 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, touchCoverageGap, } 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
|
|
@@ -137,13 +230,15 @@ function readTsserverSyncGraceMs() {
|
|
|
137
230
|
}
|
|
138
231
|
/**
|
|
139
232
|
* Read the `PI_LENS_AUX_GRACE_MS` env override at call time (not module
|
|
140
|
-
* load time) so tests can set it per-case. Controls how long
|
|
141
|
-
* promises (opengrep, ast-grep, zizmor, …) are waited after
|
|
142
|
-
* promises have settled in both getDiagnostics
|
|
143
|
-
* touchFile push wait
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
233
|
+
* load time) so tests can set it per-case. Controls the CEILING on how long
|
|
234
|
+
* auxiliary-role promises (opengrep, ast-grep, zizmor, …) are waited after
|
|
235
|
+
* all primary-role promises have settled, in both getDiagnostics
|
|
236
|
+
* (raceToCompletion) and the touchFile push wait (#1458 S2 — the two lanes
|
|
237
|
+
* share the same declared-budget-capped-by-ceiling shape). Each auxiliary
|
|
238
|
+
* still gets only its OWN declared `aggregateWaitMs` up to this ceiling —
|
|
239
|
+
* this is not a flat per-touch wait. Returns undefined when the var is
|
|
240
|
+
* absent; each call site then supplies its own default ceiling (touchFile:
|
|
241
|
+
* 2000ms; getDiagnostics: 2000ms — see the `?? 2000` at each call site).
|
|
147
242
|
*/
|
|
148
243
|
function readEnvAuxGraceMs() {
|
|
149
244
|
const raw = process.env.PI_LENS_AUX_GRACE_MS;
|
|
@@ -279,7 +374,7 @@ const WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE = (() => {
|
|
|
279
374
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 8;
|
|
280
375
|
})();
|
|
281
376
|
// #584: opengrep has no `workspace/diagnostic` pull support (push-only,
|
|
282
|
-
// docs/servercapabilities.md) and `reopenOnResync: true` (
|
|
377
|
+
// docs/servercapabilities.md) and `reopenOnResync: true` (wait-policy/strategies.ts)
|
|
283
378
|
// means every per-file LSP touch already forces a full re-scan anyway — there's
|
|
284
379
|
// no incremental win from routing it through the sweep's per-file loop. On a
|
|
285
380
|
// full workspace sweep it instead dominates the per-file wait (its own
|
|
@@ -390,11 +485,50 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
|
|
|
390
485
|
export class LSPService {
|
|
391
486
|
state;
|
|
392
487
|
workspaceProbeLogged = new Set();
|
|
488
|
+
/** Per-service immutable root-boundary verdicts; root detectors cache hits too. */
|
|
489
|
+
projectBoundaryCache = new Map();
|
|
393
490
|
warmStartLogged = new Set();
|
|
394
491
|
optionalFailureLogged = new Set();
|
|
492
|
+
/** Server/root pairs that already emitted unavailable for the current occurrence. */
|
|
493
|
+
unavailableLogged = new Set();
|
|
395
494
|
optionalDisabled = new Set();
|
|
396
495
|
/** Consecutive failure counts for exponential backoff circuit breaker */
|
|
397
496
|
failureCounts = new Map();
|
|
497
|
+
/**
|
|
498
|
+
* #1127: consecutive EARLY post-init runtime exits — a client that closed
|
|
499
|
+
* unexpectedly (not via our own `shutdown()`) with an uptime under
|
|
500
|
+
* {@link RUNTIME_EXIT_UPTIME_THRESHOLD_MS}. Tracked separately from
|
|
501
|
+
* {@link failureCounts} because a successful spawn/initialize clears that
|
|
502
|
+
* map (correct for the spawn/init failure class it was built for — see
|
|
503
|
+
* `spawnClient`'s success path) but is exactly the event a crash-loop
|
|
504
|
+
* server keeps producing right before it dies again; reusing the same map
|
|
505
|
+
* would make every respawn erase the streak the moment it re-spawned,
|
|
506
|
+
* which is the #1127 bug. This counter shares the SAME cooldown formula,
|
|
507
|
+
* the SAME `state.broken`/`permanentlyBroken` maps, and the SAME
|
|
508
|
+
* give-up-after-N-failures threshold as the spawn/init breaker — it is a
|
|
509
|
+
* parallel counter feeding one circuit, not a second mechanism.
|
|
510
|
+
*/
|
|
511
|
+
runtimeExitCounts = new Map();
|
|
512
|
+
/**
|
|
513
|
+
* #1142: rolling window of recent non-intentional death timestamps
|
|
514
|
+
* (`exitedAt`) per "serverId:root" key (the SAME identity as
|
|
515
|
+
* {@link runtimeExitCounts} and {@link LSPState.broken} — defect shape 1) —
|
|
516
|
+
* the SECOND, independent breaker trip condition that COMPOSES with the
|
|
517
|
+
* {@link runtimeExitCounts} fast path. That counter only sees CONSECUTIVE
|
|
518
|
+
* exits under the 60s threshold; a server that dies just PAST it every spawn
|
|
519
|
+
* resets that streak forever (#1142). This window trips when
|
|
520
|
+
* {@link RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths fall within
|
|
521
|
+
* {@link RUNTIME_EXIT_WINDOW_MS}, regardless of each death's lifetime.
|
|
522
|
+
*
|
|
523
|
+
* Bounded on BOTH axes (defect shape 9): entries older than the window are
|
|
524
|
+
* pruned on every record, AND the array is hard-capped at the trip count
|
|
525
|
+
* (drop-oldest) — a rate trip needs only the count within the window, never
|
|
526
|
+
* an unbounded history of timestamps. No timer backs it (defect shape 4): it
|
|
527
|
+
* ages purely by prune-on-check, so there is nothing to `unref()` or
|
|
528
|
+
* print-mode-gate. Like {@link runtimeExitCounts} it is a per-instance field,
|
|
529
|
+
* so a fresh `LSPService` (via `resetLSPService`) starts empty.
|
|
530
|
+
*/
|
|
531
|
+
runtimeExitWindow = new Map();
|
|
398
532
|
/** Server/root keys disabled for the rest of this session after repeated failures. */
|
|
399
533
|
permanentlyBroken = new Set();
|
|
400
534
|
/**
|
|
@@ -413,7 +547,26 @@ export class LSPService {
|
|
|
413
547
|
* those falls through to a fresh check rather than serving a stale result.
|
|
414
548
|
*/
|
|
415
549
|
lastKnownContentHash = new Map();
|
|
550
|
+
/**
|
|
551
|
+
* #1095: lazily verifies a stored {@link DiagnosticBinding} against current
|
|
552
|
+
* disk bytes, memoizing the disk fingerprint per (file, mtime) so repeated
|
|
553
|
+
* binding reads across `touchFile`/`getAllDiagnostics` within a session don't
|
|
554
|
+
* re-hash unchanged files. Owned by the service so the memo is shared.
|
|
555
|
+
*/
|
|
556
|
+
diskBindingCache = createDiskBindingCache();
|
|
416
557
|
lastDiagnosticsHealth = new Map();
|
|
558
|
+
/**
|
|
559
|
+
* Touch-debounce record of "this server already has this content", keyed
|
|
560
|
+
* "normalizedFilePath:clientScope:serverId".
|
|
561
|
+
*
|
|
562
|
+
* #743/#1253: the serverId component is load-bearing. #1253 requires that a
|
|
563
|
+
* server whose notify write never landed does NOT get an entry (otherwise the
|
|
564
|
+
* next touch debounces the re-push and the silent-clean gates read its silence
|
|
565
|
+
* as a confirmed clean). #743 requires that one wedged server must not degrade
|
|
566
|
+
* its healthy siblings. A file-level key can only satisfy one of those at a
|
|
567
|
+
* time — per-server, both hold: the stalled server simply has no entry and is
|
|
568
|
+
* re-pushed, while every sibling whose write landed keeps its own debounce.
|
|
569
|
+
*/
|
|
417
570
|
recentTouches = new Map();
|
|
418
571
|
/**
|
|
419
572
|
* #743: consecutive per-server notify-write timeout count, keyed by
|
|
@@ -424,6 +577,23 @@ export class LSPService {
|
|
|
424
577
|
* successful write clears its entry.
|
|
425
578
|
*/
|
|
426
579
|
notifyWriteBackpressureStreak = new Map();
|
|
580
|
+
/** LRU clock for capacity eviction, keyed by the canonical server/root key. */
|
|
581
|
+
clientLastUsedAt = new Map();
|
|
582
|
+
/**
|
|
583
|
+
* Manager-owned use leases close the acquisition/use gap that `isBusy()`
|
|
584
|
+
* cannot see: a caller may hold a selected client before its first request has
|
|
585
|
+
* entered the client. Eviction skips any key with an outstanding lease.
|
|
586
|
+
*/
|
|
587
|
+
clientLeases = new Map();
|
|
588
|
+
/**
|
|
589
|
+
* Per-root idle eviction for TypeScript's large, rebuildable program graph.
|
|
590
|
+
* Timers are unref'd so an idle language service cannot keep a one-shot host
|
|
591
|
+
* alive, and are removed before shutdown so a concurrent request rebuilds
|
|
592
|
+
* instead of receiving the retiring client.
|
|
593
|
+
*/
|
|
594
|
+
typeScriptIdleTimers = new Map();
|
|
595
|
+
/** Serializes capacity decisions with publication into `state.inFlight`. */
|
|
596
|
+
clientSpawnGate = Promise.resolve();
|
|
427
597
|
/** True after shutdown() has been called; blocks new operations */
|
|
428
598
|
isDestroyed = false;
|
|
429
599
|
/**
|
|
@@ -446,10 +616,218 @@ export class LSPService {
|
|
|
446
616
|
demonstratedCold: new Set(),
|
|
447
617
|
};
|
|
448
618
|
}
|
|
619
|
+
/**
|
|
620
|
+
* Resolve one server's client identity root. Root selection is hard-bounded
|
|
621
|
+
* by the session cwd, then config-only nested roots reuse an already-hosted
|
|
622
|
+
* same-server ancestor. Real nested projects retain independent clients.
|
|
623
|
+
*/
|
|
624
|
+
async resolveServerRoot(server, filePath) {
|
|
625
|
+
const candidate = await server.root(filePath);
|
|
626
|
+
if (!candidate)
|
|
627
|
+
return undefined;
|
|
628
|
+
const root = enforceLspRootCeiling(candidate, process.cwd(), filePath);
|
|
629
|
+
if (normalizeMapKey(root) === normalizeMapKey(process.cwd()))
|
|
630
|
+
return root;
|
|
631
|
+
const rootKey = normalizeMapKey(root);
|
|
632
|
+
const prefix = `${server.id}:`;
|
|
633
|
+
let nearestAncestor;
|
|
634
|
+
for (const key of new Set([
|
|
635
|
+
...this.state.clients.keys(),
|
|
636
|
+
...this.state.inFlight.keys(),
|
|
637
|
+
])) {
|
|
638
|
+
if (!key.startsWith(prefix))
|
|
639
|
+
continue;
|
|
640
|
+
const ancestorKey = key.slice(prefix.length);
|
|
641
|
+
const pathApi = isWindowsPath(ancestorKey) || isWindowsPath(rootKey)
|
|
642
|
+
? path.win32
|
|
643
|
+
: path;
|
|
644
|
+
const relative = pathApi.relative(ancestorKey, rootKey);
|
|
645
|
+
if (relative === "" ||
|
|
646
|
+
relative.startsWith("..") ||
|
|
647
|
+
pathApi.isAbsolute(relative)) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (!nearestAncestor || ancestorKey.length > nearestAncestor.length) {
|
|
651
|
+
nearestAncestor = ancestorKey;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (!nearestAncestor)
|
|
655
|
+
return root;
|
|
656
|
+
let boundary = this.projectBoundaryCache.get(rootKey);
|
|
657
|
+
if (!boundary) {
|
|
658
|
+
boundary = hasProjectBoundaryMarker(root);
|
|
659
|
+
this.projectBoundaryCache.set(rootKey, boundary);
|
|
660
|
+
}
|
|
661
|
+
if (await boundary)
|
|
662
|
+
return root;
|
|
663
|
+
return (this.state.clients.get(`${server.id}:${nearestAncestor}`)?.root ??
|
|
664
|
+
nearestAncestor);
|
|
665
|
+
}
|
|
449
666
|
/** Guard: return true if service is shutting down or shut down */
|
|
450
667
|
checkDestroyed() {
|
|
451
668
|
return this.isDestroyed;
|
|
452
669
|
}
|
|
670
|
+
async withClientSpawnGate(operation) {
|
|
671
|
+
const previous = this.clientSpawnGate;
|
|
672
|
+
let release;
|
|
673
|
+
this.clientSpawnGate = new Promise((resolve) => {
|
|
674
|
+
release = resolve;
|
|
675
|
+
});
|
|
676
|
+
await previous;
|
|
677
|
+
try {
|
|
678
|
+
return await operation();
|
|
679
|
+
}
|
|
680
|
+
finally {
|
|
681
|
+
release();
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async clientKeyFor(entry, filePath) {
|
|
685
|
+
const root = await this.resolveServerRoot(entry.info, filePath);
|
|
686
|
+
return root ? `${entry.info.id}:${normalizeMapKey(root)}` : undefined;
|
|
687
|
+
}
|
|
688
|
+
async acquireClientLease(entry, filePath) {
|
|
689
|
+
const key = await this.clientKeyFor(entry, filePath);
|
|
690
|
+
if (!key)
|
|
691
|
+
return undefined;
|
|
692
|
+
return this.withClientSpawnGate(async () => {
|
|
693
|
+
if (this.isDestroyed || this.state.clients.get(key) !== entry.client) {
|
|
694
|
+
return undefined;
|
|
695
|
+
}
|
|
696
|
+
this.clientLeases.set(key, (this.clientLeases.get(key) ?? 0) + 1);
|
|
697
|
+
return key;
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
async acquireClientLeases(entries, filePath) {
|
|
701
|
+
const keys = await Promise.all(entries.map((entry) => this.clientKeyFor(entry, filePath)));
|
|
702
|
+
if (keys.some((key) => key === undefined))
|
|
703
|
+
return undefined;
|
|
704
|
+
const keyed = entries.map((entry, index) => [keys[index], entry]);
|
|
705
|
+
return this.withClientSpawnGate(async () => {
|
|
706
|
+
if (this.isDestroyed ||
|
|
707
|
+
keyed.some(([key, entry]) => this.state.clients.get(key) !== entry.client)) {
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
for (const [key] of keyed) {
|
|
711
|
+
this.clientLeases.set(key, (this.clientLeases.get(key) ?? 0) + 1);
|
|
712
|
+
}
|
|
713
|
+
return keyed.map(([key]) => key);
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
releaseClientLease(key) {
|
|
717
|
+
const remaining = (this.clientLeases.get(key) ?? 1) - 1;
|
|
718
|
+
if (remaining > 0) {
|
|
719
|
+
this.clientLeases.set(key, remaining);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
this.clientLeases.delete(key);
|
|
723
|
+
if (this.state.clients.has(key)) {
|
|
724
|
+
this.clientLastUsedAt.set(key, Date.now());
|
|
725
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
async withClientForFileUse(filePath, maxWaitMs, hardCapMs, use) {
|
|
729
|
+
while (!this.isDestroyed) {
|
|
730
|
+
const entry = await this.getClientForFile(filePath, maxWaitMs, hardCapMs);
|
|
731
|
+
if (!entry)
|
|
732
|
+
return undefined;
|
|
733
|
+
const leaseKey = await this.acquireClientLease(entry, filePath);
|
|
734
|
+
if (!leaseKey)
|
|
735
|
+
continue;
|
|
736
|
+
try {
|
|
737
|
+
return await use(entry);
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
this.releaseClientLease(leaseKey);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return undefined;
|
|
744
|
+
}
|
|
745
|
+
async makeCapacityForClient(key) {
|
|
746
|
+
const occupiedKeys = new Set(this.state.inFlight.keys());
|
|
747
|
+
for (const [clientKey, client] of this.state.clients) {
|
|
748
|
+
if (client.isAlive())
|
|
749
|
+
occupiedKeys.add(clientKey);
|
|
750
|
+
}
|
|
751
|
+
const occupied = occupiedKeys.size;
|
|
752
|
+
if (occupied < getLspClientCeiling())
|
|
753
|
+
return true;
|
|
754
|
+
const idle = [...this.state.clients.entries()]
|
|
755
|
+
.filter(([candidateKey, client]) => candidateKey !== key &&
|
|
756
|
+
client.isAlive() &&
|
|
757
|
+
client.isBusy?.() !== true &&
|
|
758
|
+
(this.clientLeases.get(candidateKey) ?? 0) === 0)
|
|
759
|
+
.sort(([a], [b]) => (this.clientLastUsedAt.get(a) ?? this.state.clientSpawnedAt.get(a) ?? 0) -
|
|
760
|
+
(this.clientLastUsedAt.get(b) ?? this.state.clientSpawnedAt.get(b) ?? 0));
|
|
761
|
+
const victim = idle[0];
|
|
762
|
+
if (!victim) {
|
|
763
|
+
logSessionStart(`lsp client ceiling ${getLspClientCeiling()}: spawn declined; all clients are in use`);
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
const [victimKey, victimClient] = victim;
|
|
767
|
+
await victimClient.shutdown({ reason: "client_ceiling_lru" });
|
|
768
|
+
this.state.clients.delete(victimKey);
|
|
769
|
+
this.state.clientSpawnedAt.delete(victimKey);
|
|
770
|
+
this.state.demonstratedReady.delete(victimKey);
|
|
771
|
+
this.state.demonstratedCold.delete(victimKey);
|
|
772
|
+
this.clientLastUsedAt.delete(victimKey);
|
|
773
|
+
this.clearTypeScriptIdleTimer(victimKey);
|
|
774
|
+
logSessionStart(`lsp client ceiling ${getLspClientCeiling()}: evicted idle LRU ${victimKey}`);
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
clearTypeScriptIdleTimer(key) {
|
|
778
|
+
const timer = this.typeScriptIdleTimers.get(key);
|
|
779
|
+
if (timer)
|
|
780
|
+
clearTimeout(timer);
|
|
781
|
+
this.typeScriptIdleTimers.delete(key);
|
|
782
|
+
}
|
|
783
|
+
scheduleTypeScriptIdleEviction(key) {
|
|
784
|
+
if (!key.startsWith("typescript:"))
|
|
785
|
+
return;
|
|
786
|
+
// Pressure-gating these timers would require a separate reconciliation pass
|
|
787
|
+
// when the manager crosses the threshold; keep ownership simple and use the
|
|
788
|
+
// warm-LSP-friendly 20-minute default instead.
|
|
789
|
+
this.clearTypeScriptIdleTimer(key);
|
|
790
|
+
const lastUsedAt = this.clientLastUsedAt.get(key) ?? Date.now();
|
|
791
|
+
const timer = setTimeout(() => {
|
|
792
|
+
this.typeScriptIdleTimers.delete(key);
|
|
793
|
+
void this.withClientSpawnGate(async () => {
|
|
794
|
+
if (this.isDestroyed)
|
|
795
|
+
return;
|
|
796
|
+
const client = this.state.clients.get(key);
|
|
797
|
+
if (!client?.isAlive())
|
|
798
|
+
return;
|
|
799
|
+
if (client.isBusy?.() === true ||
|
|
800
|
+
(this.clientLeases.get(key) ?? 0) > 0 ||
|
|
801
|
+
(this.clientLastUsedAt.get(key) ?? 0) !== lastUsedAt) {
|
|
802
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
// Publish the cold state synchronously before awaiting teardown. A request
|
|
806
|
+
// arriving while shutdown is in progress therefore waits on the spawn gate
|
|
807
|
+
// and creates a fresh client; it can never receive this retiring one.
|
|
808
|
+
this.state.clients.delete(key);
|
|
809
|
+
this.state.clientSpawnedAt.delete(key);
|
|
810
|
+
this.state.demonstratedReady.delete(key);
|
|
811
|
+
this.state.demonstratedCold.delete(key);
|
|
812
|
+
this.clientLastUsedAt.delete(key);
|
|
813
|
+
try {
|
|
814
|
+
await client.shutdown({ reason: "typescript_idle_eviction" });
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
// The strong manager reference is already gone; shutdown is best-effort
|
|
818
|
+
// like the other eviction paths and must not reject from a timer callback.
|
|
819
|
+
}
|
|
820
|
+
logSessionStart(`lsp typescript idle eviction: released ${key}`);
|
|
821
|
+
recordDegradation({
|
|
822
|
+
kind: "ts-idle-eviction",
|
|
823
|
+
subject: key,
|
|
824
|
+
reason: "idle TypeScript client released to bound memory",
|
|
825
|
+
});
|
|
826
|
+
}).catch(() => { });
|
|
827
|
+
}, getTypeScriptIdleEvictMs());
|
|
828
|
+
timer.unref?.();
|
|
829
|
+
this.typeScriptIdleTimers.set(key, timer);
|
|
830
|
+
}
|
|
453
831
|
fingerprintContent(content) {
|
|
454
832
|
if (content.length <= 96) {
|
|
455
833
|
return `${content.length}:${content}`;
|
|
@@ -461,10 +839,15 @@ export class LSPService {
|
|
|
461
839
|
* NOT need diagnostics — those callers still need to wait for the LSP to
|
|
462
840
|
* publish, even if the notify itself is a no-op.
|
|
463
841
|
*/
|
|
464
|
-
shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics) {
|
|
842
|
+
shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics, serverIds) {
|
|
465
843
|
if (waitForDiagnostics)
|
|
466
844
|
return false;
|
|
467
|
-
|
|
845
|
+
// #743: only short-circuit the whole call when EVERY spawned server already
|
|
846
|
+
// has this content. If even one still needs the push, fall through — the
|
|
847
|
+
// write loop skips the servers that are covered and pushes only the rest.
|
|
848
|
+
if (serverIds.length === 0)
|
|
849
|
+
return false;
|
|
850
|
+
return serverIds.every((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId));
|
|
468
851
|
}
|
|
469
852
|
/**
|
|
470
853
|
* Should the didOpen/didChange notify be skipped while keeping the
|
|
@@ -478,12 +861,14 @@ export class LSPService {
|
|
|
478
861
|
* with diagnosticsMode="document" moments later. Without this check the
|
|
479
862
|
* second call's notify clears in-progress diagnostics and the LSP has to
|
|
480
863
|
* start over — observed as multi-second waits on slow TS projects.
|
|
864
|
+
*
|
|
865
|
+
* Decided PER SERVER (#743): a sibling's stalled write must not cost this
|
|
866
|
+
* server its debounce. See {@link recentTouches}.
|
|
481
867
|
*/
|
|
482
|
-
shouldSkipNotify(filePath, content, clientScope) {
|
|
868
|
+
shouldSkipNotify(filePath, content, clientScope, serverId) {
|
|
483
869
|
if (TOUCH_DEBOUNCE_MS <= 0)
|
|
484
870
|
return false;
|
|
485
|
-
const
|
|
486
|
-
const previous = this.recentTouches.get(key);
|
|
871
|
+
const previous = this.recentTouches.get(this.recentTouchKey(filePath, clientScope, serverId));
|
|
487
872
|
if (!previous)
|
|
488
873
|
return false;
|
|
489
874
|
const now = Date.now();
|
|
@@ -491,8 +876,11 @@ export class LSPService {
|
|
|
491
876
|
return false;
|
|
492
877
|
return previous.fingerprint === this.fingerprintContent(content);
|
|
493
878
|
}
|
|
494
|
-
|
|
495
|
-
|
|
879
|
+
recentTouchKey(filePath, clientScope, serverId) {
|
|
880
|
+
return `${normalizeMapKey(filePath)}:${clientScope}:${serverId}`;
|
|
881
|
+
}
|
|
882
|
+
markTouched(filePath, content, clientScope, serverId) {
|
|
883
|
+
const key = this.recentTouchKey(filePath, clientScope, serverId);
|
|
496
884
|
const now = Date.now();
|
|
497
885
|
this.recentTouches.set(key, {
|
|
498
886
|
fingerprint: this.fingerprintContent(content),
|
|
@@ -501,8 +889,10 @@ export class LSPService {
|
|
|
501
889
|
});
|
|
502
890
|
// Trim entries that are already past the debounce window — shouldSkipTouch
|
|
503
891
|
// ignores them anyway, so they serve no purpose. Only sweep when the map
|
|
504
|
-
// exceeds the threshold to avoid iterating on every call.
|
|
505
|
-
|
|
892
|
+
// exceeds the threshold to avoid iterating on every call. The threshold is
|
|
893
|
+
// per-ENTRY and entries are now per-server, so it is scaled up to keep the
|
|
894
|
+
// same effective file capacity a file-level key gave a multi-server scope.
|
|
895
|
+
if (this.recentTouches.size > 800) {
|
|
506
896
|
for (const [k, v] of this.recentTouches) {
|
|
507
897
|
if (now - v.touchedAt > TOUCH_DEBOUNCE_MS) {
|
|
508
898
|
this.recentTouches.delete(k);
|
|
@@ -522,7 +912,7 @@ export class LSPService {
|
|
|
522
912
|
* implementation that doesn't independently stamp `.root`).
|
|
523
913
|
*/
|
|
524
914
|
async demonstratedReadyKeyFor(server, filePath) {
|
|
525
|
-
const root = await
|
|
915
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
526
916
|
if (!root)
|
|
527
917
|
return undefined;
|
|
528
918
|
return `${server.id}:${normalizeMapKey(root)}`;
|
|
@@ -534,6 +924,9 @@ export class LSPService {
|
|
|
534
924
|
// the negative cache.
|
|
535
925
|
this.state.demonstratedCold.delete(key);
|
|
536
926
|
}
|
|
927
|
+
recordBreaker(key, reason) {
|
|
928
|
+
recordDegradationOnce({ kind: "lsp-breaker", subject: key, reason });
|
|
929
|
+
}
|
|
537
930
|
/**
|
|
538
931
|
* #743: record one notify-write timeout for a server and, once it has stalled
|
|
539
932
|
* {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row, demote it through
|
|
@@ -558,6 +951,8 @@ export class LSPService {
|
|
|
558
951
|
this.state.clients.delete(key);
|
|
559
952
|
this.state.clientSpawnedAt.delete(key);
|
|
560
953
|
this.state.demonstratedReady.delete(key);
|
|
954
|
+
this.clientLastUsedAt.delete(key);
|
|
955
|
+
this.clearTypeScriptIdleTimer(key);
|
|
561
956
|
logLatency({
|
|
562
957
|
type: "phase",
|
|
563
958
|
phase: "lsp_notify_backpressure_broken",
|
|
@@ -633,14 +1028,25 @@ export class LSPService {
|
|
|
633
1028
|
return spawned;
|
|
634
1029
|
}
|
|
635
1030
|
}
|
|
1031
|
+
const unavailable = (await Promise.all(servers.map(async (server) => {
|
|
1032
|
+
const root = await server.root(filePath);
|
|
1033
|
+
return {
|
|
1034
|
+
server,
|
|
1035
|
+
key: `${server.id}:${root ? normalizeMapKey(root) : "<unresolved>"}`,
|
|
1036
|
+
};
|
|
1037
|
+
}))).filter(({ key }) => !this.unavailableLogged.has(key));
|
|
1038
|
+
for (const { key } of unavailable)
|
|
1039
|
+
this.unavailableLogged.add(key);
|
|
1040
|
+
if (unavailable.length === 0)
|
|
1041
|
+
return undefined;
|
|
636
1042
|
logLatency({
|
|
637
1043
|
type: "phase",
|
|
638
1044
|
phase: "lsp_client_unavailable",
|
|
639
1045
|
filePath,
|
|
640
1046
|
durationMs: 0,
|
|
641
1047
|
metadata: {
|
|
642
|
-
candidateCount:
|
|
643
|
-
servers:
|
|
1048
|
+
candidateCount: unavailable.length,
|
|
1049
|
+
servers: unavailable.map(({ server }) => server.id),
|
|
644
1050
|
},
|
|
645
1051
|
});
|
|
646
1052
|
return undefined;
|
|
@@ -649,10 +1055,31 @@ export class LSPService {
|
|
|
649
1055
|
return withBudget();
|
|
650
1056
|
}
|
|
651
1057
|
const timeoutSentinel = Symbol("lsp-client-wait-timeout");
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
1058
|
+
// #1097: store the timer and clear it once the race settles. Without this,
|
|
1059
|
+
// when `withBudget()` wins (the common case: the client is ready well before
|
|
1060
|
+
// the budget), the losing `setTimeout` stays a REF'D pending timer for the
|
|
1061
|
+
// full remaining `effectiveMaxWaitMs`. In a long-lived interactive session
|
|
1062
|
+
// that is invisible (it fires later, resolves an orphan promise, is GC'd).
|
|
1063
|
+
// In a one-shot `pi --print --no-session` process it is fatal: the timer
|
|
1064
|
+
// keeps the event loop alive for up to `effectiveMaxWaitMs` after
|
|
1065
|
+
// `agent_settled`/`session_shutdown`, so the completed process never exits
|
|
1066
|
+
// (issue #1097 — a recurrence of the #22 symptom via a different handle, and
|
|
1067
|
+
// a member of the uncleared-race-timeout class the shared `withDeadline`
|
|
1068
|
+
// helper already guards against elsewhere).
|
|
1069
|
+
let waitTimer;
|
|
1070
|
+
let waitResult;
|
|
1071
|
+
try {
|
|
1072
|
+
waitResult = await Promise.race([
|
|
1073
|
+
withBudget(),
|
|
1074
|
+
new Promise((resolve) => {
|
|
1075
|
+
waitTimer = setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs);
|
|
1076
|
+
}),
|
|
1077
|
+
]);
|
|
1078
|
+
}
|
|
1079
|
+
finally {
|
|
1080
|
+
if (waitTimer)
|
|
1081
|
+
clearTimeout(waitTimer);
|
|
1082
|
+
}
|
|
656
1083
|
if (waitResult === timeoutSentinel) {
|
|
657
1084
|
// Snapshot known client health — scan by serverId prefix (no root needed)
|
|
658
1085
|
const knownHealth = [...this.state.clients.entries()]
|
|
@@ -691,7 +1118,7 @@ export class LSPService {
|
|
|
691
1118
|
return { clients: [], serverCountAttempted: 0 };
|
|
692
1119
|
// Count servers with a valid root as "attempted" — extension-only matches
|
|
693
1120
|
// that fail the root check are not real spawn attempts.
|
|
694
|
-
const roots = await Promise.all(servers.map((s) =>
|
|
1121
|
+
const roots = await Promise.all(servers.map((s) => this.resolveServerRoot(s, filePath)));
|
|
695
1122
|
const serverCountAttempted = roots.filter(Boolean).length;
|
|
696
1123
|
const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
|
|
697
1124
|
return {
|
|
@@ -722,7 +1149,7 @@ export class LSPService {
|
|
|
722
1149
|
return undefined;
|
|
723
1150
|
const servers = getServersForFileWithConfig(filePath);
|
|
724
1151
|
for (const server of servers) {
|
|
725
|
-
const root = await
|
|
1152
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
726
1153
|
if (!root)
|
|
727
1154
|
continue;
|
|
728
1155
|
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
@@ -744,7 +1171,7 @@ export class LSPService {
|
|
|
744
1171
|
for (const server of getServersForFileWithConfig(filePath)) {
|
|
745
1172
|
if (server.id !== serverId)
|
|
746
1173
|
continue;
|
|
747
|
-
const root = await
|
|
1174
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
748
1175
|
if (!root)
|
|
749
1176
|
continue;
|
|
750
1177
|
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
@@ -763,10 +1190,10 @@ export class LSPService {
|
|
|
763
1190
|
if (this.checkDestroyed())
|
|
764
1191
|
return undefined;
|
|
765
1192
|
}
|
|
766
|
-
const root = await
|
|
1193
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
767
1194
|
if (!root || this.checkDestroyed())
|
|
768
1195
|
return undefined;
|
|
769
|
-
const allowInstall = this.shouldAllowInstall(
|
|
1196
|
+
const allowInstall = this.shouldAllowInstall(server.id);
|
|
770
1197
|
const normalizedRoot = normalizeMapKey(root);
|
|
771
1198
|
const key = `${server.id}:${normalizedRoot}`;
|
|
772
1199
|
const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
|
|
@@ -803,6 +1230,9 @@ export class LSPService {
|
|
|
803
1230
|
const existing = this.state.clients.get(key);
|
|
804
1231
|
if (existing) {
|
|
805
1232
|
if (existing.isAlive()) {
|
|
1233
|
+
this.unavailableLogged.delete(key);
|
|
1234
|
+
this.clientLastUsedAt.set(key, Date.now());
|
|
1235
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
806
1236
|
if (!this.warmStartLogged.has(key)) {
|
|
807
1237
|
logSessionStart(`lsp warm-start ${server.id}: reused root=${root} file=${filePath}`);
|
|
808
1238
|
this.warmStartLogged.add(key);
|
|
@@ -811,6 +1241,28 @@ export class LSPService {
|
|
|
811
1241
|
}
|
|
812
1242
|
// Dead client — was previously alive, now needs respawn
|
|
813
1243
|
const spawnedAt = this.state.clientSpawnedAt.get(key);
|
|
1244
|
+
// #1127: capture BEFORE calling existing.shutdown() below — both
|
|
1245
|
+
// wasShutdownIntentional() and getExitedAt() read state that
|
|
1246
|
+
// existing.shutdown() itself mutates as a side effect of our own
|
|
1247
|
+
// cleanup (shutdownRequested flips true, and the process exit it
|
|
1248
|
+
// triggers would stamp exitedAt at CLEANUP time rather than at the
|
|
1249
|
+
// real death), so both must be read before that call.
|
|
1250
|
+
const wasIntentional = existing.wasShutdownIntentional();
|
|
1251
|
+
const exitedAt = existing.getExitedAt();
|
|
1252
|
+
// #1127: lifetime MUST be measured from the client's own recorded
|
|
1253
|
+
// death (exitedAt), not from "now" (this detection). Detection is
|
|
1254
|
+
// lazy — the next file attach — and #1127's real-world pattern is
|
|
1255
|
+
// attach-triggered respawns minutes to HOURS apart: a server that
|
|
1256
|
+
// died 5s after spawning but wasn't detected until an hour later
|
|
1257
|
+
// must still read as an early, breaker-worthy exit. Fall back to the
|
|
1258
|
+
// detection-time delta only when exitedAt is somehow unset (the
|
|
1259
|
+
// client's own exit handlers always set it before isAlive() can go
|
|
1260
|
+
// false, so this is a defensive fallback, not the expected path).
|
|
1261
|
+
const uptimeMs = exitedAt != null && spawnedAt != null
|
|
1262
|
+
? exitedAt - spawnedAt
|
|
1263
|
+
: spawnedAt != null
|
|
1264
|
+
? Date.now() - spawnedAt
|
|
1265
|
+
: null;
|
|
814
1266
|
logLatency({
|
|
815
1267
|
type: "phase",
|
|
816
1268
|
phase: "lsp_server_respawn",
|
|
@@ -819,7 +1271,8 @@ export class LSPService {
|
|
|
819
1271
|
metadata: {
|
|
820
1272
|
serverId: server.id,
|
|
821
1273
|
root,
|
|
822
|
-
uptimeMs
|
|
1274
|
+
uptimeMs,
|
|
1275
|
+
intentional: wasIntentional,
|
|
823
1276
|
},
|
|
824
1277
|
});
|
|
825
1278
|
try {
|
|
@@ -830,7 +1283,127 @@ export class LSPService {
|
|
|
830
1283
|
}
|
|
831
1284
|
this.state.clients.delete(key);
|
|
832
1285
|
this.state.clientSpawnedAt.delete(key);
|
|
1286
|
+
this.clientLastUsedAt.delete(key);
|
|
1287
|
+
this.clearTypeScriptIdleTimer(key);
|
|
833
1288
|
this.state.broken.delete(key);
|
|
1289
|
+
// #1127: count EARLY, non-intentional runtime exits toward the circuit
|
|
1290
|
+
// breaker. Spawn/initialize itself succeeded here (this client made it
|
|
1291
|
+
// into state.clients), so `failureCounts` — the spawn/init breaker —
|
|
1292
|
+
// never saw this failure and was cleared to 0 on that earlier success.
|
|
1293
|
+
// Without this, a server that spawns fine and then dies shortly after
|
|
1294
|
+
// serving (opengrep's post-init "Unhandled message" crash, #1122/#1127)
|
|
1295
|
+
// respawns forever: cooldown/permanent-disable never engage.
|
|
1296
|
+
//
|
|
1297
|
+
// Deliberate teardowns (session reset, #743 notify-backpressure
|
|
1298
|
+
// eviction, a generation handoff) call `shutdown()` themselves and set
|
|
1299
|
+
// `shutdownRequested` before the process exits — those must never count,
|
|
1300
|
+
// or a legitimate restart would wrongly march the server toward
|
|
1301
|
+
// permanent disablement.
|
|
1302
|
+
if (wasIntentional) {
|
|
1303
|
+
// Not evidence of a bad server — leave any existing runtime-exit
|
|
1304
|
+
// streak alone (a deliberate restart is not a recovery signal
|
|
1305
|
+
// either; only clear the streak once the server proves itself by
|
|
1306
|
+
// staying up past the threshold, handled in the `else` below). The
|
|
1307
|
+
// windowed-rate trip below is likewise gated behind this
|
|
1308
|
+
// `!wasIntentional` guard, so user-initiated restarts, config/
|
|
1309
|
+
// workspace reloads, session changes, #743 eviction and generation
|
|
1310
|
+
// handoffs (all shutdown()-driven) never feed it.
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
// #1142: windowed-rate trip — the SECOND, independent breaker
|
|
1314
|
+
// condition, composed with the fast path below. Record this
|
|
1315
|
+
// non-intentional death and, if RUNTIME_EXIT_WINDOW_TRIP_COUNT
|
|
1316
|
+
// deaths fell within the rolling window, give up NOW regardless of
|
|
1317
|
+
// each death's individual lifetime. This catches the slow loop
|
|
1318
|
+
// (dies just past the 60s threshold every spawn) that the
|
|
1319
|
+
// consecutive-early-exit fast path structurally cannot see.
|
|
1320
|
+
if (this.recordRuntimeExitWindow(key, exitedAt, uptimeMs)) {
|
|
1321
|
+
const rate = `${RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths within ${Math.round(RUNTIME_EXIT_WINDOW_MS / 60_000)}m`;
|
|
1322
|
+
// Set a cooldown so THIS detection also stops respawning (the
|
|
1323
|
+
// broken check below at the end of this method re-reads it),
|
|
1324
|
+
// symmetric with the fast path which cools down on every count.
|
|
1325
|
+
// `max` is defensive, not load-bearing under the actual call
|
|
1326
|
+
// order: `state.broken` for this key is `delete`d at the top of
|
|
1327
|
+
// this method (see the "detect dead client" branch above), so
|
|
1328
|
+
// this `.get(key) ?? 0` always reads 0 here — it's the fast
|
|
1329
|
+
// path below that runs AFTER this block and can raise the
|
|
1330
|
+
// cooldown for the same death. Harmless either way: the
|
|
1331
|
+
// non-optional branch latches `permanentlyBroken` (making the
|
|
1332
|
+
// cooldown moot), and the optional branch here is currently
|
|
1333
|
+
// unreachable (`OPTIONAL_LSP_SERVER_IDS` is empty).
|
|
1334
|
+
this.state.broken.set(key, Math.max(this.state.broken.get(key) ?? 0, Date.now() +
|
|
1335
|
+
(isOptionalServer
|
|
1336
|
+
? OPTIONAL_LSP_RETRY_COOLDOWN_MS
|
|
1337
|
+
: BROKEN_MAX_COOLDOWN_MS)));
|
|
1338
|
+
if (isOptionalServer) {
|
|
1339
|
+
// Optional servers never latch permanently (mirrors the fast
|
|
1340
|
+
// path): cool down + disable for a retry interval, and reset
|
|
1341
|
+
// the window so the trip must be re-earned afterward.
|
|
1342
|
+
this.optionalDisabled.add(key);
|
|
1343
|
+
this.runtimeExitWindow.delete(key);
|
|
1344
|
+
logSessionStart(`lsp respawn ${server.id}: optional server cooled down (windowed-rate trip: ${rate})`);
|
|
1345
|
+
}
|
|
1346
|
+
else {
|
|
1347
|
+
this.permanentlyBroken.add(key);
|
|
1348
|
+
this.recordBreaker(key, `windowed runtime-exit trip: ${rate}`);
|
|
1349
|
+
logSessionStart(`lsp respawn ${server.id}: permanently disabled (windowed-rate trip: ${rate}, uptimeMs=${uptimeMs})`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
// #1127/#1139 fast path — CONSECUTIVE exits UNDER the 60s
|
|
1353
|
+
// threshold. UNCHANGED: its own counter, its own trip point. A
|
|
1354
|
+
// single death may feed BOTH accumulators (the window above AND
|
|
1355
|
+
// runtimeExitCounts here), but neither alters the other's threshold,
|
|
1356
|
+
// so the fast path's trip point is exactly what it was.
|
|
1357
|
+
if (uptimeMs != null &&
|
|
1358
|
+
uptimeMs < RUNTIME_EXIT_UPTIME_THRESHOLD_MS) {
|
|
1359
|
+
const rCount = (this.runtimeExitCounts.get(key) ?? 0) + 1;
|
|
1360
|
+
this.runtimeExitCounts.set(key, rCount);
|
|
1361
|
+
const rCooldown = Math.min(BROKEN_BASE_COOLDOWN_MS * 2 ** (rCount - 1), BROKEN_MAX_COOLDOWN_MS);
|
|
1362
|
+
this.state.broken.set(key, Date.now() + rCooldown);
|
|
1363
|
+
if (!isOptionalServer && rCount >= BROKEN_PERMANENT_AFTER) {
|
|
1364
|
+
this.permanentlyBroken.add(key);
|
|
1365
|
+
this.recordBreaker(key, `permanently disabled after ${rCount} early post-init exits`);
|
|
1366
|
+
logSessionStart(`lsp respawn ${server.id}: permanently disabled after ${rCount} early post-init exits (uptimeMs=${uptimeMs})`);
|
|
1367
|
+
}
|
|
1368
|
+
else {
|
|
1369
|
+
logSessionStart(`lsp respawn ${server.id}: early post-init exit ${rCount}/${BROKEN_PERMANENT_AFTER} (uptimeMs=${uptimeMs}), cooldown=${rCooldown}ms`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
// Survived past the threshold (or uptime unknown) before dying —
|
|
1374
|
+
// this was a functioning server, not a HOT crash loop. Reset the
|
|
1375
|
+
// consecutive-early streak so an occasional post-longrun crash
|
|
1376
|
+
// doesn't creep it toward permanent disablement. The windowed
|
|
1377
|
+
// counter above is deliberately NOT reset here: a ~65-90s death
|
|
1378
|
+
// lands in this branch yet IS the churn #1142 targets — the
|
|
1379
|
+
// window ages those out by time instead.
|
|
1380
|
+
this.runtimeExitCounts.delete(key);
|
|
1381
|
+
// Outer-map hygiene (#1183): runtimeExitWindow itself is only
|
|
1382
|
+
// ever `delete`d on the optional-trip branch above (currently
|
|
1383
|
+
// unreachable — OPTIONAL_LSP_SERVER_IDS is empty), so a key
|
|
1384
|
+
// that crashed once then recovered would otherwise retain a
|
|
1385
|
+
// stale timestamp array forever, even once every entry in it
|
|
1386
|
+
// has aged out of the window. Drop it here once nothing in it
|
|
1387
|
+
// is still in-window — purely map hygiene: a later death
|
|
1388
|
+
// rebuilds the array from scratch via recordRuntimeExitWindow,
|
|
1389
|
+
// so this cannot change trip behavior. Anchor "now" on this
|
|
1390
|
+
// death's own `exitedAt` (falling back to real time only if
|
|
1391
|
+
// unset), NOT `Date.now()` directly — this death's timestamp
|
|
1392
|
+
// is what `recordRuntimeExitWindow` itself anchors pruning on
|
|
1393
|
+
// (see its `windowRef` comment), so a death that WAS just
|
|
1394
|
+
// recorded is guaranteed to still be in-window here and this
|
|
1395
|
+
// can only ever fire for the "declined to record" cases
|
|
1396
|
+
// (over the sleep-gap ceiling, or a missing `exitedAt`) where
|
|
1397
|
+
// nothing new was added and the array is genuinely stale.
|
|
1398
|
+
const windowDeaths = this.runtimeExitWindow.get(key);
|
|
1399
|
+
if (windowDeaths) {
|
|
1400
|
+
const windowNow = exitedAt ?? Date.now();
|
|
1401
|
+
if (!windowDeaths.some((t) => t >= windowNow - RUNTIME_EXIT_WINDOW_MS)) {
|
|
1402
|
+
this.runtimeExitWindow.delete(key);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
834
1407
|
}
|
|
835
1408
|
const brokenUntil = this.state.broken.get(key);
|
|
836
1409
|
if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
|
|
@@ -851,31 +1424,53 @@ export class LSPService {
|
|
|
851
1424
|
if (isOptionalServer)
|
|
852
1425
|
this.optionalDisabled.delete(key);
|
|
853
1426
|
}
|
|
854
|
-
|
|
855
|
-
if (
|
|
856
|
-
|
|
1427
|
+
let spawnPromise = this.state.inFlight.get(key);
|
|
1428
|
+
if (!spawnPromise) {
|
|
1429
|
+
const started = await this.withClientSpawnGate(async () => {
|
|
1430
|
+
const raced = this.state.inFlight.get(key);
|
|
1431
|
+
if (raced)
|
|
1432
|
+
return { promise: raced };
|
|
1433
|
+
// `server.root()` and dead-client cleanup above are async. A reset during
|
|
1434
|
+
// either gap must not let this retired generation start a late spawn.
|
|
1435
|
+
if (this.checkDestroyed())
|
|
1436
|
+
return undefined;
|
|
1437
|
+
if (!(await this.makeCapacityForClient(key)))
|
|
1438
|
+
return undefined;
|
|
1439
|
+
const promise = this.spawnClient(server, root, key, filePath, allowInstall);
|
|
1440
|
+
this.state.inFlight.set(key, promise);
|
|
1441
|
+
return { promise };
|
|
1442
|
+
});
|
|
1443
|
+
if (!started)
|
|
1444
|
+
return undefined;
|
|
1445
|
+
spawnPromise = started.promise;
|
|
857
1446
|
}
|
|
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
1447
|
try {
|
|
866
1448
|
return await spawnPromise;
|
|
867
1449
|
}
|
|
868
1450
|
finally {
|
|
869
|
-
this.state.inFlight.
|
|
1451
|
+
if (this.state.inFlight.get(key) === spawnPromise) {
|
|
1452
|
+
this.state.inFlight.delete(key);
|
|
1453
|
+
}
|
|
870
1454
|
}
|
|
871
1455
|
}
|
|
872
|
-
shouldAllowInstall(
|
|
1456
|
+
shouldAllowInstall(serverId) {
|
|
1457
|
+
if (!assertInstallAllowed(`lsp install: ${serverId}`))
|
|
1458
|
+
return false;
|
|
873
1459
|
return process.env.PI_LENS_DISABLE_LSP_INSTALL !== "1";
|
|
874
1460
|
}
|
|
875
1461
|
/**
|
|
876
1462
|
* Internal: spawn a client for a server/root combination
|
|
877
1463
|
*/
|
|
878
1464
|
async spawnClient(server, root, key, filePath, allowInstall) {
|
|
1465
|
+
// #1334 S5: honor the host project-trust decision before executing any
|
|
1466
|
+
// project-resolved binary. Only an explicit host "not trusted" blocks —
|
|
1467
|
+
// a host with no trust surface (`"unknown"`) spawns exactly as before.
|
|
1468
|
+
// Deliberately NOT marked broken: trust is a policy outcome, not a server
|
|
1469
|
+
// failure, and the user may grant trust later in the same session.
|
|
1470
|
+
if (!isLspSpawnAllowedByTrust()) {
|
|
1471
|
+
logSessionStart(`lsp spawn ${server.id}: refused — ${projectTrustDenialReason()}`);
|
|
1472
|
+
return undefined;
|
|
1473
|
+
}
|
|
879
1474
|
const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
|
|
880
1475
|
const startedAt = Date.now();
|
|
881
1476
|
logSessionStart(`lsp spawn ${server.id}: start root=${root} install=${allowInstall ? "enabled" : "disabled"} file=${filePath}`);
|
|
@@ -914,6 +1509,7 @@ export class LSPService {
|
|
|
914
1509
|
this.state.broken.set(key, Date.now() + uCooldown);
|
|
915
1510
|
if (uCount >= BROKEN_PERMANENT_AFTER) {
|
|
916
1511
|
this.permanentlyBroken.add(key);
|
|
1512
|
+
this.recordBreaker(key, `permanently disabled after ${uCount} unavailable spawns`);
|
|
917
1513
|
logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${uCount} failures`);
|
|
918
1514
|
}
|
|
919
1515
|
return undefined;
|
|
@@ -943,7 +1539,10 @@ export class LSPService {
|
|
|
943
1539
|
diagnosticProviderKind: "unavailable",
|
|
944
1540
|
};
|
|
945
1541
|
this.state.clients.set(key, client);
|
|
1542
|
+
this.unavailableLogged.delete(key);
|
|
946
1543
|
this.state.clientSpawnedAt.set(key, Date.now());
|
|
1544
|
+
this.clientLastUsedAt.set(key, Date.now());
|
|
1545
|
+
this.scheduleTypeScriptIdleEviction(key);
|
|
947
1546
|
this.failureCounts.delete(key);
|
|
948
1547
|
if (isOptionalServer) {
|
|
949
1548
|
this.optionalDisabled.delete(key);
|
|
@@ -973,6 +1572,7 @@ export class LSPService {
|
|
|
973
1572
|
this.state.broken.set(key, Date.now() + eCooldown);
|
|
974
1573
|
if (!isOptionalServer && eCount >= BROKEN_PERMANENT_AFTER) {
|
|
975
1574
|
this.permanentlyBroken.add(key);
|
|
1575
|
+
this.recordBreaker(key, `permanently disabled after ${eCount} spawn/initialize failures`);
|
|
976
1576
|
logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${eCount} failures`);
|
|
977
1577
|
}
|
|
978
1578
|
if (isOptionalServer) {
|
|
@@ -987,11 +1587,10 @@ export class LSPService {
|
|
|
987
1587
|
async openFile(filePath, content, options) {
|
|
988
1588
|
if (this.checkDestroyed())
|
|
989
1589
|
return;
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
|
|
1590
|
+
await this.withClientForFileUse(filePath, undefined, options?.spawnBudgetMs, async (spawned) => {
|
|
1591
|
+
const languageId = getLanguageId(filePath) ?? "plaintext";
|
|
1592
|
+
await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
|
|
1593
|
+
});
|
|
995
1594
|
}
|
|
996
1595
|
/**
|
|
997
1596
|
* Update file content (sends textDocument/didChange)
|
|
@@ -999,10 +1598,7 @@ export class LSPService {
|
|
|
999
1598
|
async updateFile(filePath, content) {
|
|
1000
1599
|
if (this.checkDestroyed())
|
|
1001
1600
|
return;
|
|
1002
|
-
|
|
1003
|
-
if (!spawned)
|
|
1004
|
-
return;
|
|
1005
|
-
await spawned.client.notify.change(filePath, content);
|
|
1601
|
+
await this.withClientForFileUse(filePath, undefined, undefined, (spawned) => spawned.client.notify.change(filePath, content));
|
|
1006
1602
|
}
|
|
1007
1603
|
/**
|
|
1008
1604
|
* Touch a file like OpenCode's LSP flow: ensure document is open/synced,
|
|
@@ -1064,533 +1660,822 @@ export class LSPService {
|
|
|
1064
1660
|
});
|
|
1065
1661
|
return;
|
|
1066
1662
|
}
|
|
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 [];
|
|
1663
|
+
const leaseKeys = await this.acquireClientLeases(spawned, filePath);
|
|
1664
|
+
if (!leaseKeys) {
|
|
1665
|
+
// An idle eviction won between selection and lease admission. Resolve the
|
|
1666
|
+
// now-current client set and replay; no notification targets the retiree.
|
|
1667
|
+
return this.touchFile(filePath, content, options);
|
|
1084
1668
|
}
|
|
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) {
|
|
1669
|
+
try {
|
|
1670
|
+
const spawnedServerIds = spawned.map((entry) => entry.info.id);
|
|
1671
|
+
if (this.shouldSkipTouch(filePath, content, clientScope, diagnosticsMode !== "none", spawnedServerIds)) {
|
|
1141
1672
|
logLatency({
|
|
1142
1673
|
type: "phase",
|
|
1143
|
-
phase: "
|
|
1674
|
+
phase: "lsp_touch_file",
|
|
1144
1675
|
filePath: normalizedPath,
|
|
1145
1676
|
durationMs: Date.now() - startedAt,
|
|
1146
1677
|
metadata: {
|
|
1147
|
-
|
|
1678
|
+
serverCountReady: spawned.length,
|
|
1148
1679
|
clientScope,
|
|
1149
|
-
|
|
1150
|
-
|
|
1680
|
+
diagnosticsMode,
|
|
1681
|
+
source,
|
|
1682
|
+
failureKind: "success",
|
|
1683
|
+
skipped: true,
|
|
1684
|
+
reason: "debounced_unchanged_content",
|
|
1151
1685
|
},
|
|
1152
1686
|
});
|
|
1687
|
+
// #1179: a debounced skip collected nothing — resolve the wrapper's
|
|
1688
|
+
// `.diags` as empty with neither flag (exactly the pre-wrapper `[]`).
|
|
1689
|
+
return { diags: [] };
|
|
1153
1690
|
}
|
|
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.
|
|
1691
|
+
const languageId = getLanguageId(filePath) ?? "plaintext";
|
|
1692
|
+
const silent = options.silent ?? false;
|
|
1693
|
+
// When the same content was already pushed to the LSP within the touch
|
|
1694
|
+
// debounce window, skip the notify — pushing again clears the LSP's
|
|
1695
|
+
// diagnostic cache (via notify.open) and forces it to restart work it
|
|
1696
|
+
// already did. This is what makes the post-write touch + dispatch-lsp-
|
|
1697
|
+
// runner touch sequence expensive on slow TS projects.
|
|
1224
1698
|
//
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1699
|
+
// #743: resolved PER SERVER. A server whose sibling's write stalled last
|
|
1700
|
+
// touch still holds its own debounce entry, so it is skipped here while the
|
|
1701
|
+
// stalled server (which has no entry) gets re-pushed. `notifySkipped` stays
|
|
1702
|
+
// as the file-level "every server was skipped" summary for the logs and the
|
|
1703
|
+
// no-new-version baseline below.
|
|
1704
|
+
const notifySkippedServerIds = new Set(spawnedServerIds.filter((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId)));
|
|
1705
|
+
const notifySkipped = spawned.length > 0 && notifySkippedServerIds.size === spawned.length;
|
|
1706
|
+
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
|
|
1707
|
+
// #1458: read a late auxiliary publication BEFORE the ordinary resync
|
|
1708
|
+
// clears its client cache. Carry it only when the publication's exact
|
|
1709
|
+
// sent-content fingerprint matches this touch's content. A changed edit,
|
|
1710
|
+
// version-less publication, or malformed binding fails closed and is not
|
|
1711
|
+
// replayed. The fresh notify still runs below, so scanners continue toward
|
|
1712
|
+
// a publication for this touch while the prior late result reaches the read.
|
|
1713
|
+
const touchContentHash = this.hashContent(content);
|
|
1714
|
+
const carriedAuxiliary = options.collectDiagnostics
|
|
1715
|
+
? spawned.flatMap((entry) => {
|
|
1716
|
+
if (entry.info.role !== "auxiliary")
|
|
1717
|
+
return [];
|
|
1718
|
+
const binding = entry.client.getDiagnosticBinding?.(filePath);
|
|
1719
|
+
if (binding?.contentHash !== touchContentHash)
|
|
1720
|
+
return [];
|
|
1721
|
+
const diags = entry.client.getDiagnostics(filePath);
|
|
1722
|
+
return diags.length > 0 ? [{ diags, binding }] : [];
|
|
1723
|
+
})
|
|
1724
|
+
: [];
|
|
1725
|
+
// #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
|
|
1726
|
+
// write gets its OWN notifyWriteBudgetMs budget rather than one shared
|
|
1727
|
+
// deadline over a single Promise.all — otherwise one backpressured server
|
|
1728
|
+
// (stalled stdin) times out the write for the ENTIRE file, flipping every
|
|
1729
|
+
// co-touched healthy server to inconclusive and zeroing its diagnostics.
|
|
1730
|
+
// Bounded so a backpressured write can't hang the caller; on timeout we
|
|
1731
|
+
// proceed — the diagnostics wait below is separately bounded and simply
|
|
1732
|
+
// returns no fresh diagnostics for the server(s) that stalled.
|
|
1733
|
+
//
|
|
1734
|
+
// Holds the serverId of every server whose write did NOT land in time.
|
|
1735
|
+
// The file-level `notifyWriteTimedOut` (logged below) means "at least one
|
|
1736
|
+
// server timed out"; this list carries the per-server detail the
|
|
1737
|
+
// demonstratedReady gate reads so a healthy sibling stays eligible.
|
|
1738
|
+
const notifyWriteTimedOutServerIds = [];
|
|
1739
|
+
if (!notifySkipped) {
|
|
1740
|
+
const budget = notifyWriteBudgetMs();
|
|
1741
|
+
await Promise.all(spawned.map(async (entry) => {
|
|
1742
|
+
// #743: this server already has this content from a recent touch
|
|
1743
|
+
// that landed. Pushing again would clear its diagnostic cache for
|
|
1744
|
+
// nothing — leave its debounce entry (and its original timestamp)
|
|
1745
|
+
// alone so the window still expires naturally.
|
|
1746
|
+
if (notifySkippedServerIds.has(entry.info.id))
|
|
1747
|
+
return;
|
|
1748
|
+
// Same identity as the broken/demonstratedReady maps.
|
|
1749
|
+
const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
1750
|
+
let wrote;
|
|
1751
|
+
let rejected = false;
|
|
1234
1752
|
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
|
-
}
|
|
1753
|
+
wrote = await withDeadline(entry.client.notify
|
|
1754
|
+
.open(filePath, content, languageId, undefined, silent)
|
|
1755
|
+
.then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
|
|
1248
1756
|
}
|
|
1249
1757
|
catch {
|
|
1250
|
-
//
|
|
1758
|
+
// The write itself rejected (not backpressure): the content did
|
|
1759
|
+
// not land, so this server is inconclusive for the touch, but a
|
|
1760
|
+
// rejection is not a stdin-backpressure signal and must not count
|
|
1761
|
+
// toward the backpressure demotion streak.
|
|
1762
|
+
rejected = true;
|
|
1251
1763
|
}
|
|
1764
|
+
if (wrote === true) {
|
|
1765
|
+
// A clean write clears any accrued backpressure streak (#743).
|
|
1766
|
+
if (clientKey)
|
|
1767
|
+
this.notifyWriteBackpressureStreak.delete(clientKey);
|
|
1768
|
+
// #1253: record the debounce entry for THIS server only, and only
|
|
1769
|
+
// because its own write landed. A server whose write timed out or
|
|
1770
|
+
// rejected falls through without an entry, so the next touch
|
|
1771
|
+
// re-pushes it instead of laundering the failure into a later
|
|
1772
|
+
// touch that looks fully delivered (which the silent-clean gates
|
|
1773
|
+
// would then read as a confirmed clean).
|
|
1774
|
+
this.markTouched(filePath, content, clientScope, entry.info.id);
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
notifyWriteTimedOutServerIds.push(entry.info.id);
|
|
1778
|
+
if (!rejected) {
|
|
1779
|
+
this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}));
|
|
1783
|
+
if (notifyWriteTimedOutServerIds.length > 0) {
|
|
1784
|
+
logLatency({
|
|
1785
|
+
type: "phase",
|
|
1786
|
+
phase: "lsp_notify_timeout",
|
|
1787
|
+
filePath: normalizedPath,
|
|
1788
|
+
durationMs: Date.now() - startedAt,
|
|
1789
|
+
metadata: {
|
|
1790
|
+
source,
|
|
1791
|
+
clientScope,
|
|
1792
|
+
serverCount: spawned.length,
|
|
1793
|
+
timedOutServerIds: notifyWriteTimedOutServerIds,
|
|
1794
|
+
},
|
|
1795
|
+
});
|
|
1252
1796
|
}
|
|
1253
1797
|
}
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
//
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1798
|
+
// File-level flag: at least one server's write timed out (kept for the
|
|
1799
|
+
// conservative touch-wide `inconclusive` merge semantics — see below).
|
|
1800
|
+
const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
|
|
1801
|
+
let diagnosticsTimedOut = false;
|
|
1802
|
+
// R8 (#714): server ids of aux-role servers whose push wait was cut off by
|
|
1803
|
+
// the aux grace window. Undefined when no aux was cut off (primary-only
|
|
1804
|
+
// paths never set this). Logged in lsp_touch_file metadata.
|
|
1805
|
+
let auxCutOffServerIds;
|
|
1806
|
+
// #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
|
|
1807
|
+
// full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
|
|
1808
|
+
// holds the sync commands' answer when the racing confirm won the wait
|
|
1809
|
+
// (undefined = the race didn't produce an answer; the end-of-wait
|
|
1810
|
+
// fallback below may still fill it in on a timed-out empty result).
|
|
1811
|
+
let tsserverSyncEligible = false;
|
|
1812
|
+
let tsserverSyncConfirmed;
|
|
1813
|
+
if (diagnosticsMode !== "none") {
|
|
1814
|
+
// Resolution: env wins so users can tune the cap without rebuilding.
|
|
1815
|
+
// Otherwise, on the single-server hot path (primary scope), use that
|
|
1816
|
+
// server's own strategy budget (wait-policy/strategies.ts) so a fast server
|
|
1817
|
+
// (TypeScript ~1s) isn't held to a flat multi-second wait while a slow
|
|
1818
|
+
// one (rust-analyzer 3s) gets the time it needs — bounded by any caller
|
|
1819
|
+
// ceiling that exists to protect the per-edit pipeline budget (#203).
|
|
1820
|
+
// #573: clientScope "all" (lsp_diagnostics, lens_diagnostics_full) now
|
|
1821
|
+
// gets the same per-server treatment as "with-auxiliary" — each spawned
|
|
1822
|
+
// server (primary + any auxiliaries) is bounded by ITS OWN strategy
|
|
1823
|
+
// budget instead of one flat number shared by every server. This was
|
|
1824
|
+
// never a deliberate "all means wait for the group ceiling" semantic:
|
|
1825
|
+
// #203 introduced perServerTimeout only for the single-server primary
|
|
1826
|
+
// path and left "full"/"all" on the pre-existing flat resolution
|
|
1827
|
+
// ("full/cascade path unchanged"); #242 later added "with-auxiliary"
|
|
1828
|
+
// without revisiting "all". The one property "all" genuinely needs —
|
|
1829
|
+
// the touch's overall detection deadline is the SLOWEST spawned
|
|
1830
|
+
// server's budget, not the fastest — is unaffected: `timeoutMs` below
|
|
1831
|
+
// is always `Math.max(...spawned.map(timeoutFor))` regardless of which
|
|
1832
|
+
// timeoutFor is selected, so a slow auxiliary still gets to run to its
|
|
1833
|
+
// own budget before the touch is logged as timed out. What changes is
|
|
1834
|
+
// only that a fast server's *individual* `waitForDiagnostics` call
|
|
1835
|
+
// (further below) now resolves/times out against its own budget
|
|
1836
|
+
// instead of blocking to the flat multi-server number.
|
|
1837
|
+
const envWait = readEnvDiagnosticsWaitMs();
|
|
1838
|
+
const callerCap = options.maxDiagnosticsWaitMs ?? options.maxClientWaitMs;
|
|
1839
|
+
const modeFloor = diagnosticsMode === "full" ? 3000 : 1200;
|
|
1840
|
+
// #645: resolve each spawned server's "is this the first same-sweep
|
|
1841
|
+
// touch for it" verdict EXACTLY ONCE up front, before `perServerTimeout`
|
|
1842
|
+
// is defined. `SweepIndexGate.consumeFirstTouch` is side-effecting
|
|
1843
|
+
// (it marks the server seen), and `perServerTimeout` below is invoked
|
|
1844
|
+
// twice per server in this call (once to compute the overall
|
|
1845
|
+
// `timeoutMs` deadline, again inside the wait `Promise.all`) — calling
|
|
1846
|
+
// the gate directly from inside `perServerTimeout` would consume the
|
|
1847
|
+
// "first touch" slot on the first of those two calls and read as
|
|
1848
|
+
// already-warm on the second, silently shortchanging the very touch
|
|
1849
|
+
// that was supposed to get the full budget.
|
|
1850
|
+
const sweepFirstTouch = new Map();
|
|
1851
|
+
if (options.sweepIndexGate) {
|
|
1852
|
+
for (const entry of spawned) {
|
|
1853
|
+
const strategy = getStrategy(entry.client.serverId, entry.client.getLaunchVariant?.());
|
|
1854
|
+
if (strategy.workspaceIndexing) {
|
|
1855
|
+
sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1274
1858
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1859
|
+
// #832: workspace-indexing servers that are classified as silent on
|
|
1860
|
+
// clean do not benefit from the generic cold-indexing floor. Their
|
|
1861
|
+
// configured strategy already gives the first sweep touch a bounded
|
|
1862
|
+
// workspace-index budget (marksman: 1500ms), while the capability
|
|
1863
|
+
// classification proves that a clean push has no affirmative signal to
|
|
1864
|
+
// wait for. Keep this restricted to the workspace-indexing strategy:
|
|
1865
|
+
// TypeScript is also a silent-on-clean push server, but its cold project
|
|
1866
|
+
// load still needs the longer 20s floor.
|
|
1867
|
+
//
|
|
1868
|
+
// Build this from the live spawned client's capabilities rather than
|
|
1869
|
+
// server id alone. Missing/throwing capability data fails closed, so a
|
|
1870
|
+
// new or ambiguous server keeps the existing generous warm-up budget.
|
|
1871
|
+
const silentCleanWarmupServers = new Set();
|
|
1872
|
+
if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
|
|
1873
|
+
for (const entry of spawned) {
|
|
1874
|
+
const strategy = getStrategy(entry.client.serverId, entry.client.getLaunchVariant?.());
|
|
1875
|
+
if (strategy.workspaceIndexing !== true)
|
|
1876
|
+
continue;
|
|
1877
|
+
try {
|
|
1878
|
+
const snapshot = {
|
|
1879
|
+
serverId: entry.client.serverId,
|
|
1880
|
+
root: entry.client.root,
|
|
1881
|
+
operationSupport: entry.client.getOperationSupport(),
|
|
1882
|
+
workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
|
|
1883
|
+
advertisedCommands: entry.client.getAdvertisedCommands(),
|
|
1884
|
+
rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
|
|
1885
|
+
launchVariant: entry.client.getLaunchVariant?.(),
|
|
1886
|
+
};
|
|
1887
|
+
if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
|
|
1888
|
+
"tier3-silent") {
|
|
1889
|
+
silentCleanWarmupServers.add(entry.client.serverId);
|
|
1890
|
+
}
|
|
1287
1891
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
if ((options.warmupAttempt ?? 1) > 1) {
|
|
1291
|
-
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1892
|
+
catch {
|
|
1893
|
+
// Fail closed: capability uncertainty must retain the cold floor.
|
|
1292
1894
|
}
|
|
1293
|
-
return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
|
|
1294
1895
|
}
|
|
1295
|
-
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1296
1896
|
}
|
|
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
|
-
|
|
1897
|
+
// Each server gets its OWN deadline, bounded by the caller cap as a
|
|
1898
|
+
// CEILING (never a floor) — so a clean push-silent primary (typescript
|
|
1899
|
+
// ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
|
|
1900
|
+
// slow aux (opengrep) can't override the per-edit cap. Resolves as soon
|
|
1901
|
+
// as a server publishes; this is just its individual deadline. (#242)
|
|
1902
|
+
const perServerTimeout = (serverId) => {
|
|
1903
|
+
const launchVariant = spawned.find((entry) => entry.client.serverId === serverId)?.client.getLaunchVariant?.();
|
|
1904
|
+
const strategy = getStrategy(serverId, launchVariant);
|
|
1905
|
+
let strategyWait = strategy.aggregateWaitMs;
|
|
1906
|
+
// #645: a `workspaceIndexing` server (marksman) only needs the
|
|
1907
|
+
// full budget for the FIRST same-sweep touch to it — every
|
|
1908
|
+
// subsequent touch in this sweep uses the much shorter warm-wait
|
|
1909
|
+
// instead, since the one-time index build only needs to finish
|
|
1910
|
+
// once. `sweepFirstTouch` only has entries when a sweep gate was
|
|
1911
|
+
// passed in AND the strategy is marked, so a per-edit touch
|
|
1912
|
+
// (no gate) or an unmarked server is completely unaffected.
|
|
1913
|
+
const isFirstTouch = sweepFirstTouch.get(serverId);
|
|
1914
|
+
if (isFirstTouch === false && strategy.workspaceIndexing) {
|
|
1915
|
+
strategyWait =
|
|
1916
|
+
strategy.workspaceIndexingWarmWaitMs ??
|
|
1917
|
+
Math.min(300, strategyWait);
|
|
1918
|
+
}
|
|
1919
|
+
if (callerCap !== undefined) {
|
|
1920
|
+
// #669: `ensureWarmForSweep`'s cold-server warm-up wants its cap
|
|
1921
|
+
// to act as a FLOOR (give it at least this much, possibly more
|
|
1922
|
+
// if the strategy already wants more) rather than the normal
|
|
1923
|
+
// ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
|
|
1924
|
+
if (options.warmupOverride) {
|
|
1925
|
+
// #832: a workspace-indexing server already classified as
|
|
1926
|
+
// silent-on-clean uses its strategy's bounded wait on the first
|
|
1927
|
+
// attempt; the generic cold floor is for servers whose cold work
|
|
1928
|
+
// can eventually produce a push answer (notably TypeScript).
|
|
1929
|
+
if (silentCleanWarmupServers.has(serverId)) {
|
|
1930
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1931
|
+
}
|
|
1932
|
+
// #799: only the FIRST warm-up attempt for a cold server gets the
|
|
1933
|
+
// floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
|
|
1934
|
+
if ((options.warmupAttempt ?? 1) > 1) {
|
|
1935
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1936
|
+
}
|
|
1937
|
+
return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
|
|
1938
|
+
}
|
|
1939
|
+
return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
|
|
1940
|
+
}
|
|
1941
|
+
return strategyWait > 0 ? strategyWait : modeFloor;
|
|
1942
|
+
};
|
|
1943
|
+
let timeoutFor;
|
|
1944
|
+
if (envWait !== undefined) {
|
|
1945
|
+
// Env override is a single flat cap so users can tune without rebuilding.
|
|
1946
|
+
timeoutFor = () => envWait;
|
|
1340
1947
|
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1948
|
+
else if ((!useAllClients && spawned.length === 1) ||
|
|
1949
|
+
clientScope === "with-auxiliary" ||
|
|
1950
|
+
clientScope === "all") {
|
|
1951
|
+
timeoutFor = perServerTimeout;
|
|
1343
1952
|
}
|
|
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);
|
|
1953
|
+
else {
|
|
1954
|
+
// Fail-safe for any future clientScope this branch hasn't been
|
|
1955
|
+
// taught about yet — keep the old flat resolution rather than
|
|
1956
|
+
// silently mis-budgeting an unrecognized scope.
|
|
1957
|
+
timeoutFor = () => callerCap ?? modeFloor;
|
|
1370
1958
|
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1959
|
+
// Detection deadline = the slowest individual server's budget.
|
|
1960
|
+
const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
|
|
1961
|
+
// #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
|
|
1962
|
+
// starts. Cheap synchronous gates first (notify succeeded, collecting,
|
|
1963
|
+
// primary scope, `serverId === "typescript"` — the sync commands this
|
|
1964
|
+
// races are tsserver-specific protocol extensions, not a generic
|
|
1965
|
+
// push-only capability, so #799 giving other servers (marksman) the
|
|
1966
|
+
// SAME `silentOnClean` marker must not route them into a sync attempt
|
|
1967
|
+
// that can never succeed for them), then the live capability-snapshot
|
|
1968
|
+
// tier classification (`classifyCascadeWaitTier`, which also excludes
|
|
1969
|
+
// native-ts7 via `launchVariant`). Every other server fails this
|
|
1970
|
+
// synchronous gate and pays ZERO extra work — not even the snapshot
|
|
1971
|
+
// read; a non-typescript `silentOnClean` server instead gets the
|
|
1972
|
+
// generic (non-racing) clean-confirm fallback further below.
|
|
1973
|
+
if (!notifyWriteTimedOut &&
|
|
1974
|
+
options.collectDiagnostics === true &&
|
|
1975
|
+
clientScope === "primary" &&
|
|
1976
|
+
spawned.length === 1 &&
|
|
1977
|
+
spawned[0].client.serverId === "typescript" &&
|
|
1978
|
+
getStrategy(spawned[0].client.serverId, spawned[0].client.getLaunchVariant?.()).silentOnClean === true) {
|
|
1979
|
+
try {
|
|
1980
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
1981
|
+
tsserverSyncEligible =
|
|
1982
|
+
classifyCascadeWaitTier(this, filePath, snapshots) ===
|
|
1983
|
+
"tier3-silent";
|
|
1984
|
+
}
|
|
1985
|
+
catch {
|
|
1986
|
+
// Fail-safe: ineligible — today's full wait, no sync attempt.
|
|
1987
|
+
}
|
|
1373
1988
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1989
|
+
const waitStartedAt = Date.now();
|
|
1990
|
+
// R8 (#714): on the with-auxiliary path, apply a bounded aux grace so a
|
|
1991
|
+
// slow auxiliary no longer holds the push wait to its own deadline.
|
|
1992
|
+
// Primary waits resolve on their own per-server budget; once ALL primaries
|
|
1993
|
+
// have settled the auxiliaries get at most auxGraceMs before we proceed.
|
|
1994
|
+
// Primary-only and "all"/"primary" scopes are completely unaffected —
|
|
1995
|
+
// they fall through to the original Promise.all path below.
|
|
1996
|
+
//
|
|
1997
|
+
// "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
|
|
1998
|
+
// In the with-auxiliary spawn list, `getClientForFile` returns the
|
|
1999
|
+
// language-primary entry first and `getAuxiliaryClientsForFile` appends
|
|
2000
|
+
// the rest — but we use info.role rather than position so the logic is
|
|
2001
|
+
// correct even if ordering shifts in the future.
|
|
2002
|
+
//
|
|
2003
|
+
// The #707 tsserver sync race operates exclusively on single-server
|
|
2004
|
+
// primary-scope touches (guarded by `clientScope === "primary" &&
|
|
2005
|
+
// spawned.length === 1`), so there is NO interaction with this path.
|
|
2006
|
+
// #1458 S4: also gated on `collectDiagnostics` — a non-collecting
|
|
2007
|
+
// with-auxiliary touch has nothing to carry the aux wait's result
|
|
2008
|
+
// INTO (its diagnostics are discarded either way), so paying up to
|
|
2009
|
+
// `auxCeilingMs` of extra latency for it buys nothing. Both current
|
|
2010
|
+
// callers (`getDiagnostics`'s with-auxiliary path and the cascade's
|
|
2011
|
+
// collecting touch) already pass `collectDiagnostics: true`, so this
|
|
2012
|
+
// is latent-today defense, not a behavior change — but a future
|
|
2013
|
+
// non-collecting with-auxiliary caller must not silently inherit the
|
|
2014
|
+
// full aux-grace cost for diagnostics it's about to throw away.
|
|
2015
|
+
const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
|
|
2016
|
+
options.collectDiagnostics === true &&
|
|
2017
|
+
spawned.some((e) => e.info.role === "auxiliary");
|
|
2018
|
+
// Per-server wait promises (each already bounded by its own
|
|
2019
|
+
// perServerTimeout — unchanged from before R8).
|
|
2020
|
+
let pressureSnapshots = [];
|
|
2021
|
+
if (shouldPreferPullOnlyDiagnostics()) {
|
|
2022
|
+
try {
|
|
2023
|
+
pressureSnapshots = await this.getCapabilitySnapshots(filePath);
|
|
2024
|
+
}
|
|
2025
|
+
catch {
|
|
2026
|
+
// Fail-open: missing capability state keeps today's push fallback.
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
const perServerWaits = spawned.map((entry) => {
|
|
2030
|
+
const serverTimeout = timeoutFor(entry.client.serverId);
|
|
2031
|
+
const baseline = diagnosticBaselines.get(entry.client);
|
|
2032
|
+
const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
|
|
2033
|
+
// #743: per-server — a server we DID push to still gets the
|
|
2034
|
+
// version-baseline wait even when a sibling was debounced away.
|
|
2035
|
+
const wait = !notifySkippedServerIds.has(entry.info.id) && Number.isFinite(baseline)
|
|
1385
2036
|
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
1386
|
-
|
|
2037
|
+
minVersion: baseline,
|
|
2038
|
+
...(pullOnly && { pullOnly: true }),
|
|
1387
2039
|
})
|
|
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
|
-
return;
|
|
1415
|
-
const auxTimeout = new Promise((resolve) => {
|
|
1416
|
-
const t = setTimeout(() => resolve("timeout"), auxGraceMs);
|
|
1417
|
-
if (typeof t === "object" && "unref" in t)
|
|
1418
|
-
t.unref?.();
|
|
1419
|
-
});
|
|
1420
|
-
const auxAll = Promise.all(auxWaits.map((a) => a.promise)).then(() => "done");
|
|
1421
|
-
const outcome = await Promise.race([auxAll, auxTimeout]);
|
|
1422
|
-
if (outcome === "timeout") {
|
|
1423
|
-
// Record which auxiliaries did NOT finish in time.
|
|
1424
|
-
const unfinished = [];
|
|
1425
|
-
for (const a of auxWaits) {
|
|
1426
|
-
let done = false;
|
|
1427
|
-
// Check synchronously if already resolved by racing against
|
|
1428
|
-
// a resolved promise.
|
|
1429
|
-
await Promise.race([
|
|
1430
|
-
a.promise.then(() => {
|
|
1431
|
-
done = true;
|
|
1432
|
-
}),
|
|
1433
|
-
Promise.resolve(),
|
|
1434
|
-
]);
|
|
1435
|
-
if (!done)
|
|
1436
|
-
unfinished.push(a.serverId);
|
|
2040
|
+
: pullOnly
|
|
2041
|
+
? entry.client.waitForDiagnostics(filePath, serverTimeout, {
|
|
2042
|
+
pullOnly: true,
|
|
2043
|
+
})
|
|
2044
|
+
: entry.client.waitForDiagnostics(filePath, serverTimeout);
|
|
2045
|
+
return wait.catch(() => undefined);
|
|
2046
|
+
});
|
|
2047
|
+
// The push wait — same per-server budget composition as before #707;
|
|
2048
|
+
// only the awaiting changed (assigned so it can be raced below).
|
|
2049
|
+
let pushWaitSettled = false;
|
|
2050
|
+
const pushWait = hasTouchAuxiliaries
|
|
2051
|
+
? (() => {
|
|
2052
|
+
// Primary waits: all non-auxiliary servers.
|
|
2053
|
+
const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
|
|
2054
|
+
// Aux waits: auxiliary servers (advisory). `client` and the
|
|
2055
|
+
// pre-notify `diagnosticsVersion` baseline travel alongside the
|
|
2056
|
+
// promise so the outcome can be decided from EVIDENCE after the
|
|
2057
|
+
// race, not from how the raced promise settled (#1458 S1 — see
|
|
2058
|
+
// below).
|
|
2059
|
+
const auxWaits = perServerWaits
|
|
2060
|
+
.map((p, i) => spawned[i].info.role === "auxiliary"
|
|
2061
|
+
? {
|
|
2062
|
+
promise: p,
|
|
2063
|
+
serverId: spawned[i].info.id,
|
|
2064
|
+
client: spawned[i].client,
|
|
2065
|
+
baseline: diagnosticBaselines.get(spawned[i].client),
|
|
1437
2066
|
}
|
|
2067
|
+
: null)
|
|
2068
|
+
.filter((x) => x !== null);
|
|
2069
|
+
const auxCeilingMs = readEnvAuxGraceMs() ?? 2000;
|
|
2070
|
+
// After all primaries settle, give each auxiliary the smaller of
|
|
2071
|
+
// its declared wait budget and the global auxiliary ceiling. The
|
|
2072
|
+
// 2000ms default admits measured ~1.3s warm scanner runs without
|
|
2073
|
+
// making every edit pay opengrep's 3500ms cold-start allowance.
|
|
2074
|
+
// Late aux results are dropped from this wait. A later unchanged-
|
|
2075
|
+
// content read may carry a SHA-256-bound cache publication before its
|
|
2076
|
+
// resync clears the cache; changed or unknown content never replays.
|
|
2077
|
+
// Aux servers that answer within the grace are included automatically since
|
|
2078
|
+
// their waitForDiagnostics already resolved. The cut-off server ids
|
|
2079
|
+
// are logged in the latency metadata (lsp_touch_file phase, field
|
|
2080
|
+
// `auxCutOffServerIds`).
|
|
2081
|
+
return Promise.all(primaryWaits).then(async () => {
|
|
2082
|
+
if (auxWaits.length === 0)
|
|
2083
|
+
return;
|
|
2084
|
+
const auxWaitStartedAt = Date.now();
|
|
2085
|
+
const outcomes = await Promise.all(auxWaits.map(async (aux) => {
|
|
2086
|
+
const budgetMs = Math.min(timeoutFor(aux.serverId), auxCeilingMs);
|
|
2087
|
+
let timer;
|
|
2088
|
+
const timeout = new Promise((resolve) => {
|
|
2089
|
+
timer = setTimeout(() => resolve(false), budgetMs);
|
|
2090
|
+
if (typeof timer === "object" && "unref" in timer) {
|
|
2091
|
+
timer.unref?.();
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
const raced = await Promise.race([
|
|
2095
|
+
aux.promise.then(() => true),
|
|
2096
|
+
timeout,
|
|
2097
|
+
]);
|
|
2098
|
+
if (timer)
|
|
2099
|
+
clearTimeout(timer);
|
|
2100
|
+
// #1458 S1: `waitForDiagnostics` RESOLVES on its own timeout
|
|
2101
|
+
// (client.ts) — it never rejects, and a silent scanner that
|
|
2102
|
+
// published nothing looks identical, promise-wise, to one
|
|
2103
|
+
// that answered. `raced === true` only means "the promise
|
|
2104
|
+
// settled before our timer fired"; it is not proof anything
|
|
2105
|
+
// was published. Decide the outcome from evidence instead:
|
|
2106
|
+
// did this aux's `diagnosticsVersion` advance past the
|
|
2107
|
+
// pre-notify baseline captured before the wait started?
|
|
2108
|
+
// - raced === false → "cut_off" (our timer won;
|
|
2109
|
+
// the aux's own wait never got to answer for itself).
|
|
2110
|
+
// - raced === true, no evidence → "silent" (the aux's own
|
|
2111
|
+
// wait gave up within its budget with nothing to report —
|
|
2112
|
+
// NOT the same as having answered).
|
|
2113
|
+
// - raced === true, evidence → "answered" (a fresh
|
|
2114
|
+
// publication actually landed for this touch).
|
|
2115
|
+
const publishedEvidence = raced &&
|
|
2116
|
+
Number.isFinite(aux.baseline) &&
|
|
2117
|
+
aux.client.diagnosticsVersion > aux.baseline;
|
|
2118
|
+
const outcome = !raced
|
|
2119
|
+
? "cut_off"
|
|
2120
|
+
: publishedEvidence
|
|
2121
|
+
? "answered"
|
|
2122
|
+
: "silent";
|
|
2123
|
+
return {
|
|
2124
|
+
serverId: aux.serverId,
|
|
2125
|
+
outcome,
|
|
2126
|
+
budgetMs,
|
|
2127
|
+
elapsedMs: Date.now() - auxWaitStartedAt,
|
|
2128
|
+
// #1458 S3: elapsed measured from BEFORE the primary wait
|
|
2129
|
+
// (waitStartedAt), not just from auxWaitStartedAt — this is
|
|
2130
|
+
// what lets a latency row validate the ~1.3s warm-scanner
|
|
2131
|
+
// figure the 2000ms ceiling was set from; `elapsedMs` alone
|
|
2132
|
+
// only covers the POST-primary aux phase.
|
|
2133
|
+
elapsedSinceNotifyMs: Date.now() - waitStartedAt,
|
|
2134
|
+
};
|
|
2135
|
+
}));
|
|
2136
|
+
const unfinished = outcomes
|
|
2137
|
+
.filter((outcome) => outcome.outcome === "cut_off")
|
|
2138
|
+
.map((outcome) => outcome.serverId);
|
|
1438
2139
|
if (unfinished.length > 0)
|
|
1439
2140
|
auxCutOffServerIds = unfinished;
|
|
2141
|
+
logLatency({
|
|
2142
|
+
type: "phase",
|
|
2143
|
+
phase: "lsp_aux_wait_outcome",
|
|
2144
|
+
filePath: normalizedPath,
|
|
2145
|
+
durationMs: Date.now() - auxWaitStartedAt,
|
|
2146
|
+
metadata: { clientScope, outcomes },
|
|
2147
|
+
});
|
|
2148
|
+
});
|
|
2149
|
+
})()
|
|
2150
|
+
: Promise.all(perServerWaits).then(() => { });
|
|
2151
|
+
pushWait.then(() => {
|
|
2152
|
+
pushWaitSettled = true;
|
|
2153
|
+
});
|
|
2154
|
+
if (tsserverSyncEligible) {
|
|
2155
|
+
// #707 racing variant: rather than burning the full push-wait budget
|
|
2156
|
+
// on a silent-on-clean server (which by definition never answers on a
|
|
2157
|
+
// clean file), race the push wait against a grace-delayed sync
|
|
2158
|
+
// confirm. The grace (default 300ms, PI_LENS_TSSERVER_SYNC_GRACE_MS)
|
|
2159
|
+
// gives a genuinely dirty file's push a head start: if diagnostics
|
|
2160
|
+
// arrive before the grace elapses, the sync request never goes out —
|
|
2161
|
+
// zero new latency or requests on the push-answers path.
|
|
2162
|
+
//
|
|
2163
|
+
// Race semantics:
|
|
2164
|
+
// - sync answers first → that's the confirmed result (clean OR
|
|
2165
|
+
// dirty — the sync commands return the file's real syntactic +
|
|
2166
|
+
// semantic state, so a dirty-file win is still correct and its
|
|
2167
|
+
// findings are surfaced, never discarded).
|
|
2168
|
+
// - push settles first → push wins; a still-in-flight sync outcome
|
|
2169
|
+
// is discarded (the racer checks `pushWaitSettled` after the
|
|
2170
|
+
// call returns and drops its own result).
|
|
2171
|
+
// - sync unavailable/fails → the racer parks on a never-resolving
|
|
2172
|
+
// promise so the race is decided by the push wait's own budget,
|
|
2173
|
+
// exactly today's behavior (the end-of-wait fallback below still
|
|
2174
|
+
// gets its shot on a timed-out empty result).
|
|
2175
|
+
// The racer never rejects (every failure path is caught), so the
|
|
2176
|
+
// losing promise can never surface as an unhandled rejection.
|
|
2177
|
+
const graceMs = readTsserverSyncGraceMs();
|
|
2178
|
+
const primaryClient = spawned[0].client;
|
|
2179
|
+
// Resolves with the sync commands' diagnostics when the confirm
|
|
2180
|
+
// succeeds; parks on a never-resolving promise on EVERY other path
|
|
2181
|
+
// (push already answered, sync unavailable/failed, push won while
|
|
2182
|
+
// in flight) so the race is then decided by the push wait's own
|
|
2183
|
+
// budget — exactly today's behavior.
|
|
2184
|
+
const syncRacer = (async () => {
|
|
2185
|
+
await new Promise((resolve) => {
|
|
2186
|
+
const timer = setTimeout(resolve, graceMs);
|
|
2187
|
+
timer.unref?.();
|
|
2188
|
+
});
|
|
2189
|
+
// Push already answered (settled, or published diagnostics that
|
|
2190
|
+
// its wait is about to settle on) — nothing to confirm, no sync
|
|
2191
|
+
// request goes out.
|
|
2192
|
+
if (pushWaitSettled ||
|
|
2193
|
+
primaryClient.getDiagnostics(filePath).length > 0) {
|
|
2194
|
+
return new Promise(() => { });
|
|
1440
2195
|
}
|
|
2196
|
+
try {
|
|
2197
|
+
const result = await attemptTsserverSyncDiagnostics(filePath, this);
|
|
2198
|
+
if (result === undefined || pushWaitSettled) {
|
|
2199
|
+
// Sync unavailable/failed, or push won while the sync call
|
|
2200
|
+
// was in flight — drop the sync outcome and let the push
|
|
2201
|
+
// wait decide the race.
|
|
2202
|
+
return new Promise(() => { });
|
|
2203
|
+
}
|
|
2204
|
+
return result;
|
|
2205
|
+
}
|
|
2206
|
+
catch {
|
|
2207
|
+
return new Promise(() => { });
|
|
2208
|
+
}
|
|
2209
|
+
})();
|
|
2210
|
+
const raceOutcome = await Promise.race([
|
|
2211
|
+
pushWait.then(() => undefined),
|
|
2212
|
+
syncRacer,
|
|
2213
|
+
]);
|
|
2214
|
+
if (raceOutcome !== undefined) {
|
|
2215
|
+
tsserverSyncConfirmed = raceOutcome;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
else {
|
|
2219
|
+
await pushWait;
|
|
2220
|
+
}
|
|
2221
|
+
const waitedMs = Date.now() - waitStartedAt;
|
|
2222
|
+
if (tsserverSyncConfirmed !== undefined) {
|
|
2223
|
+
// #707: the racing sync confirm won — a definitive answer well under
|
|
2224
|
+
// the push-wait budget. Not a timeout, not inconclusive.
|
|
2225
|
+
logLatency({
|
|
2226
|
+
type: "phase",
|
|
2227
|
+
phase: "lsp_tsserver_sync_confirm",
|
|
2228
|
+
filePath: normalizedPath,
|
|
2229
|
+
durationMs: waitedMs,
|
|
2230
|
+
metadata: {
|
|
2231
|
+
source,
|
|
2232
|
+
serverId: spawned[0]?.client.serverId,
|
|
2233
|
+
clientScope,
|
|
2234
|
+
diagnosticsMode,
|
|
2235
|
+
mode: "race",
|
|
2236
|
+
confirmedDiagnosticCount: tsserverSyncConfirmed.length,
|
|
2237
|
+
budgetMs: timeoutMs,
|
|
2238
|
+
savedVsBudgetMs: Math.max(0, timeoutMs - waitedMs),
|
|
2239
|
+
},
|
|
1441
2240
|
});
|
|
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(() => { });
|
|
2241
|
+
}
|
|
2242
|
+
else if (waitedMs + 20 >= timeoutMs) {
|
|
2243
|
+
// Within ~20 ms of the configured budget we treat it as a timeout;
|
|
2244
|
+
// the LSP didn't beat the cap. Diagnostics that arrive late still
|
|
2245
|
+
// land in the client's cache and surface on the next edit.
|
|
2246
|
+
diagnosticsTimedOut = true;
|
|
2247
|
+
for (const entry of spawned) {
|
|
2248
|
+
incrementDegradationCount({
|
|
2249
|
+
kind: "lsp-diagnostics-timeout",
|
|
2250
|
+
// `info.id` is the authoritative server identity carried by
|
|
2251
|
+
// every spawned entry. The client test doubles (and some
|
|
2252
|
+
// lightweight clients) need not expose a serverId property;
|
|
2253
|
+
// ledger recording must never abort the touch or alter #570's
|
|
2254
|
+
// timeout-preserves-last-known-diagnostics semantics.
|
|
2255
|
+
subject: entry.info.id,
|
|
2256
|
+
reason: "diagnostics wait timed out",
|
|
2257
|
+
});
|
|
1488
2258
|
}
|
|
2259
|
+
logLatency({
|
|
2260
|
+
type: "phase",
|
|
2261
|
+
phase: "lsp_diagnostics_timeout",
|
|
2262
|
+
filePath: normalizedPath,
|
|
2263
|
+
durationMs: waitedMs,
|
|
2264
|
+
metadata: {
|
|
2265
|
+
source,
|
|
2266
|
+
// #1444: WHICH server(s) burned the budget — without this the
|
|
2267
|
+
// ~221/day timeout rows can't be attributed to a server at all.
|
|
2268
|
+
// `info.id` (not `client.serverId`) for the same reason the
|
|
2269
|
+
// degradation ledger above uses it: test doubles and lightweight
|
|
2270
|
+
// clients need not expose `serverId`.
|
|
2271
|
+
serverIds: spawned.map((e) => e.info.id),
|
|
2272
|
+
clientScope,
|
|
2273
|
+
diagnosticsMode,
|
|
2274
|
+
timeoutMs,
|
|
2275
|
+
},
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
// #814: capability-aware AGGREGATE wait — generalize #799's
|
|
2279
|
+
// single-server (`clientScope === "primary" && spawned.length === 1`)
|
|
2280
|
+
// silent-clean confirm to multi-server `clientScope: "all"` touches
|
|
2281
|
+
// (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
|
|
2282
|
+
// `serverScope: "all"`). #799's gate never fires here (it's scoped to
|
|
2283
|
+
// the primary hot path), so a scope-"all" touch where every OTHER
|
|
2284
|
+
// spawned server already answered but one push-only `silentOnClean`
|
|
2285
|
+
// server (marksman on a clean markdown file) never publishes still
|
|
2286
|
+
// reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
|
|
2287
|
+
// though the "silence" is exactly what that server's own known
|
|
2288
|
+
// clean-behavior predicts — not an unresolved question.
|
|
2289
|
+
//
|
|
2290
|
+
// A spawned server counts as "still outstanding" when nothing landed
|
|
2291
|
+
// in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
|
|
2292
|
+
// is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
|
|
2293
|
+
// that file's entry as part of the didOpen/didChange this touch just
|
|
2294
|
+
// sent, so a present entry can only be a FRESH answer (found or a real
|
|
2295
|
+
// confirmed-empty push/pull), never a stale one bleeding through from
|
|
2296
|
+
// an earlier touch. This is the same "did anything publish for this
|
|
2297
|
+
// file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
|
|
2298
|
+
// already trusts (#240 doctrine) — reused here, not reinvented.
|
|
2299
|
+
//
|
|
2300
|
+
// The touch stays inconclusive unless EVERY still-outstanding server
|
|
2301
|
+
// is classified `tier3-silent` (push-only + `silentOnClean`, the same
|
|
2302
|
+
// `classifyServerWaitTier` rule the single-server gate below and the
|
|
2303
|
+
// cascade lane use) — one ordinary push-only straggler (still
|
|
2304
|
+
// genuinely analyzing) or a pull-capable server that never answered
|
|
2305
|
+
// keeps the touch cautious, matching #799's "err toward caution"
|
|
2306
|
+
// posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
|
|
2307
|
+
// plus the per-server re-check below are the same "the notify write
|
|
2308
|
+
// must have actually landed" conservatism #799 established — a
|
|
2309
|
+
// server's silence is only evidence of "clean" when we know it saw
|
|
2310
|
+
// the new content.
|
|
2311
|
+
if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
|
|
1489
2312
|
try {
|
|
1490
|
-
const
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
2313
|
+
const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
|
|
2314
|
+
!entry.client.getAllDiagnostics().has(normalizedPath));
|
|
2315
|
+
if (outstanding.length > 0) {
|
|
2316
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
2317
|
+
const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
|
|
2318
|
+
if (allSilent) {
|
|
2319
|
+
// #1277: the static tier3-silent classification alone can't
|
|
2320
|
+
// tell a genuinely clean server from one that accepted the
|
|
2321
|
+
// notify write and then wedged — both look identical to
|
|
2322
|
+
// `classifyServerWaitTier`, which only reads the capability
|
|
2323
|
+
// snapshot, never the server's actual current responsiveness.
|
|
2324
|
+
// Require every still-outstanding server to answer a cheap
|
|
2325
|
+
// bounded round-trip before trusting the silence as clean;
|
|
2326
|
+
// any server that doesn't respond in time keeps the touch
|
|
2327
|
+
// inconclusive rather than confirming a possibly-dead server
|
|
2328
|
+
// clean.
|
|
2329
|
+
const liveness = await Promise.all(outstanding.map((entry) => (entry.client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false)));
|
|
2330
|
+
if (liveness.every(Boolean)) {
|
|
2331
|
+
diagnosticsTimedOut = false;
|
|
2332
|
+
logLatency({
|
|
2333
|
+
type: "phase",
|
|
2334
|
+
phase: "lsp_silent_clean_confirm",
|
|
2335
|
+
filePath: normalizedPath,
|
|
2336
|
+
durationMs: Date.now() - startedAt,
|
|
2337
|
+
metadata: {
|
|
2338
|
+
source,
|
|
2339
|
+
clientScope,
|
|
2340
|
+
diagnosticsMode,
|
|
2341
|
+
aggregate: true,
|
|
2342
|
+
serverIds: outstanding.map((entry) => entry.client.serverId),
|
|
2343
|
+
},
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
1496
2347
|
}
|
|
1497
|
-
return result;
|
|
1498
2348
|
}
|
|
1499
2349
|
catch {
|
|
1500
|
-
|
|
2350
|
+
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's
|
|
2351
|
+
// inconclusive behavior, exactly like the single-server gate.
|
|
1501
2352
|
}
|
|
1502
|
-
})();
|
|
1503
|
-
const raceOutcome = await Promise.race([
|
|
1504
|
-
pushWait.then(() => undefined),
|
|
1505
|
-
syncRacer,
|
|
1506
|
-
]);
|
|
1507
|
-
if (raceOutcome !== undefined) {
|
|
1508
|
-
tsserverSyncConfirmed = raceOutcome;
|
|
1509
2353
|
}
|
|
1510
2354
|
}
|
|
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
|
-
|
|
2355
|
+
// #707: when the racing sync confirm won the wait, its answer IS the
|
|
2356
|
+
// collected result — the file's real syntactic + semantic state straight
|
|
2357
|
+
// from tsserver (clean = [], dirty = real findings that a silentOnClean
|
|
2358
|
+
// server had computed but never published). Otherwise merge the push
|
|
2359
|
+
// diagnostics from the client cache as always.
|
|
2360
|
+
let collected = options.collectDiagnostics
|
|
2361
|
+
? tsserverSyncConfirmed !== undefined
|
|
2362
|
+
? mergeLspDiagnostics(tsserverSyncConfirmed)
|
|
2363
|
+
: mergeLspDiagnostics([
|
|
2364
|
+
...spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)),
|
|
2365
|
+
...carriedAuxiliary.flatMap((entry) => entry.diags),
|
|
2366
|
+
])
|
|
2367
|
+
: undefined;
|
|
2368
|
+
// #1095 (P3-b): whether `collected` came from a tsserver sync confirm
|
|
2369
|
+
// (`tsserverSyncRequest`) rather than the publish cache. A sync-confirmed
|
|
2370
|
+
// result is authoritative for the CURRENT buffer but is NOT tied to the
|
|
2371
|
+
// publish-path content binding (`diagnosticBindings`, set on publish), so
|
|
2372
|
+
// composing that binding here could let a STALE publish fingerprint demote a
|
|
2373
|
+
// genuinely-fresh sync answer to `false`. The end-of-wait fallback below can
|
|
2374
|
+
// also set this. When true, the binding is surfaced as "unknown" (honest,
|
|
2375
|
+
// non-demoting) rather than the stale publish binding.
|
|
2376
|
+
let syncConfirmed = tsserverSyncConfirmed !== undefined;
|
|
2377
|
+
// #707 end-of-wait fallback: when the racing confirm did NOT decide the
|
|
2378
|
+
// wait (sync unavailable/failed mid-race, or push resolved as a bare
|
|
2379
|
+
// timeout) and the wait timed out with an empty result on an eligible
|
|
2380
|
+
// touch, give the sync clean-confirm one last shot before reporting
|
|
2381
|
+
// inconclusive. `tsserverSyncEligible` already encodes every gate (notify
|
|
2382
|
+
// succeeded, collecting, primary scope, tier3-silent classic typescript —
|
|
2383
|
+
// native-ts7 excluded by `classifyCascadeWaitTier`). If the sync call
|
|
2384
|
+
// answers (even with an empty body, which is a confirmed clean), we use
|
|
2385
|
+
// those diagnostics as the confirmed result and clear the
|
|
2386
|
+
// `diagnosticsTimedOut` flag so the touch is no longer treated as
|
|
2387
|
+
// inconclusive. Sync diagnostics on a dirty file are surfaced, not
|
|
2388
|
+
// discarded. If the sync call fails or is unavailable, we fall through to
|
|
2389
|
+
// today's behavior: `inconclusive` = true, `collected` unchanged. This
|
|
2390
|
+
// turns "unconfirmed after ~1000ms" into "confirmed at ~wait+sync-RTT"
|
|
2391
|
+
// even when the race path couldn't answer.
|
|
2392
|
+
if (diagnosticsTimedOut &&
|
|
2393
|
+
tsserverSyncEligible &&
|
|
2394
|
+
collected !== undefined &&
|
|
2395
|
+
collected.length === 0) {
|
|
2396
|
+
try {
|
|
2397
|
+
const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
|
|
2398
|
+
if (syncResult !== undefined) {
|
|
2399
|
+
// Sync answered — confirmed result (clean or with diagnostics).
|
|
2400
|
+
// Clear the timed-out flag so the touch is no longer inconclusive.
|
|
2401
|
+
diagnosticsTimedOut = false;
|
|
2402
|
+
syncConfirmed = true;
|
|
2403
|
+
collected = syncResult.length > 0
|
|
2404
|
+
? mergeLspDiagnostics(syncResult)
|
|
2405
|
+
: [];
|
|
2406
|
+
logLatency({
|
|
2407
|
+
type: "phase",
|
|
2408
|
+
phase: "lsp_tsserver_sync_confirm",
|
|
2409
|
+
filePath: normalizedPath,
|
|
2410
|
+
durationMs: Date.now() - startedAt,
|
|
2411
|
+
metadata: {
|
|
2412
|
+
source,
|
|
2413
|
+
clientScope,
|
|
2414
|
+
diagnosticsMode,
|
|
2415
|
+
mode: "end-of-wait",
|
|
2416
|
+
confirmedDiagnosticCount: collected.length,
|
|
2417
|
+
},
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
catch {
|
|
2422
|
+
// Any failure here falls through to today's inconclusive behavior.
|
|
2423
|
+
}
|
|
1551
2424
|
}
|
|
1552
|
-
// #
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1556
|
-
//
|
|
1557
|
-
// the
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
//
|
|
1563
|
-
//
|
|
1564
|
-
//
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
2425
|
+
// #799: generalize the "silent-clean push-only" confirm beyond
|
|
2426
|
+
// typescript's active sync-command race above. That mechanism is
|
|
2427
|
+
// TS-specific (`attemptTsserverSyncDiagnostics` races an actual
|
|
2428
|
+
// `typescript.tsserverRequest` — no equivalent protocol exists for
|
|
2429
|
+
// e.g. marksman) and is now scoped to `serverId === "typescript"`
|
|
2430
|
+
// only (see the gate above), so it never fires for another
|
|
2431
|
+
// `silentOnClean` server. This is the generic fallback for those
|
|
2432
|
+
// servers: if the wait ran its full budget with a successful notify
|
|
2433
|
+
// write and nothing published, and the live capability snapshot
|
|
2434
|
+
// classifies this touch as tier3-silent (push-only + `silentOnClean`,
|
|
2435
|
+
// #458's `classifyCascadeWaitTier`), that is not "still working" —
|
|
2436
|
+
// `silentOnClean` means by definition this server publishes NOTHING
|
|
2437
|
+
// on a clean transition, so a timeout under those conditions IS the
|
|
2438
|
+
// confirmed-clean answer. `!tsserverSyncEligible` keeps this from
|
|
2439
|
+
// ever double-deciding typescript's own touches — when the sync race
|
|
2440
|
+
// was attempted and failed/was unavailable, typescript's existing
|
|
2441
|
+
// "falls through to inconclusive, unchanged" contract (#707) is
|
|
2442
|
+
// preserved exactly; typescript touches that never enter that gate
|
|
2443
|
+
// (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
|
|
2444
|
+
// warm-up call) are still eligible here as a genuine bonus fix. Scoped
|
|
2445
|
+
// to `clientScope === "primary"`/`spawned.length === 1` exactly like
|
|
2446
|
+
// the sync-eligible gate above (and like `ensureWarmForSweep`'s own
|
|
2447
|
+
// `clientScope: "primary"` warm-up touch) so a multi-server
|
|
2448
|
+
// with-auxiliary/all touch — where a partial timeout must stay
|
|
2449
|
+
// cautious per the doc below — is never affected.
|
|
1573
2450
|
//
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
// `
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
2451
|
+
// #814: this is now a SPECIAL CASE of the more general per-server gate
|
|
2452
|
+
// above (the `clientScope === "all"` block right before the diagnostics-
|
|
2453
|
+
// wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
|
|
2454
|
+
// server is tier3-silent" collapses to exactly this single-server check.
|
|
2455
|
+
// Left in place unchanged (rather than deleted/rewritten to delegate to
|
|
2456
|
+
// the new gate) per #814's scope: a future cleanup could fold this block
|
|
2457
|
+
// into the general one once both have soaked, but that's a separate,
|
|
2458
|
+
// lower-risk follow-up, not bundled into this fix.
|
|
2459
|
+
if (diagnosticsTimedOut &&
|
|
2460
|
+
!notifyWriteTimedOut &&
|
|
2461
|
+
!tsserverSyncEligible &&
|
|
2462
|
+
clientScope === "primary" &&
|
|
2463
|
+
spawned.length === 1 &&
|
|
2464
|
+
getStrategy(spawned[0].client.serverId, spawned[0].client.getLaunchVariant?.()).silentOnClean === true) {
|
|
1586
2465
|
try {
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
2466
|
+
const snapshots = await this.getCapabilitySnapshots(filePath);
|
|
2467
|
+
if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
|
|
2468
|
+
// #1277: same liveness precondition as the aggregate gate above —
|
|
2469
|
+
// `tier3-silent` is a static capability classification and can't
|
|
2470
|
+
// distinguish "silent because clean" from "silent because wedged".
|
|
2471
|
+
// A cheap bounded round-trip proves the server is still actually
|
|
2472
|
+
// responding before its silence is trusted as a clean confirm; a
|
|
2473
|
+
// server that doesn't answer in time leaves the touch inconclusive.
|
|
2474
|
+
const alive = await (spawned[0].client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false);
|
|
2475
|
+
if (alive) {
|
|
1593
2476
|
diagnosticsTimedOut = false;
|
|
2477
|
+
if (collected !== undefined)
|
|
2478
|
+
collected = mergeLspDiagnostics([]);
|
|
1594
2479
|
logLatency({
|
|
1595
2480
|
type: "phase",
|
|
1596
2481
|
phase: "lsp_silent_clean_confirm",
|
|
@@ -1600,238 +2485,212 @@ export class LSPService {
|
|
|
1600
2485
|
source,
|
|
1601
2486
|
clientScope,
|
|
1602
2487
|
diagnosticsMode,
|
|
1603
|
-
|
|
1604
|
-
serverIds: outstanding.map((entry) => entry.client.serverId),
|
|
2488
|
+
serverId: spawned[0].client.serverId,
|
|
1605
2489
|
},
|
|
1606
2490
|
});
|
|
1607
2491
|
}
|
|
1608
2492
|
}
|
|
1609
2493
|
}
|
|
1610
2494
|
catch {
|
|
1611
|
-
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's
|
|
1612
|
-
//
|
|
2495
|
+
// Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
|
|
2496
|
+
// behavior.
|
|
1613
2497
|
}
|
|
1614
2498
|
}
|
|
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
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
2499
|
+
// A touch is inconclusive when EITHER the notify write or the
|
|
2500
|
+
// diagnostics wait hit their deadline for ANY of the spawned servers
|
|
2501
|
+
// (these flags are touch-wide, covering the whole `Promise.all` over
|
|
2502
|
+
// `spawned` — see the field doc on the return type). We deliberately
|
|
2503
|
+
// err toward caution here: `collected` merges diagnostics across every
|
|
2504
|
+
// spawned server, so even a partial timeout (e.g. a slow auxiliary
|
|
2505
|
+
// while the primary answered) means the merged result may be missing
|
|
2506
|
+
// findings that just hadn't arrived yet — it must not be trusted as a
|
|
2507
|
+
// confirmed answer.
|
|
2508
|
+
const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
|
|
2509
|
+
// #1470: an auxiliary whose push wait was CUT OFF by the aux grace timer
|
|
2510
|
+
// (R8/#714) contributed exactly as much evidence about this file as one that
|
|
2511
|
+
// went silent inside its own budget — none. A hung opengrep resolved
|
|
2512
|
+
// `confirmation: "confirmed"` and read as confirmed-clean on the security
|
|
2513
|
+
// lane. (The SILENT case still does, and is NOT addressed here: a scanner
|
|
2514
|
+
// that settles inside its own budget without publishing also yields an
|
|
2515
|
+
// unqualified confirmation. Same #533 class, same lane, different door —
|
|
2516
|
+
// filed as #1493, deliberately untouched by this change.)
|
|
2517
|
+
// This does NOT flip the touch to inconclusive: that would discard a
|
|
2518
|
+
// primary answer that IS trustworthy (#533 honesty doctrine cuts both ways —
|
|
2519
|
+
// overclaiming and underclaiming are both dishonest). Instead the confirmation
|
|
2520
|
+
// is NARROWED: `"partial"`, naming the servers it does not speak for, so every
|
|
2521
|
+
// consumer that treats confirmation as proof of coverage fails closed while
|
|
2522
|
+
// the primary's findings still flow.
|
|
2523
|
+
const unconfirmedServerIds = auxCutOffServerIds ?? [];
|
|
2524
|
+
const coverageGap = unconfirmedServerIds.length > 0;
|
|
2525
|
+
// #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
|
|
2526
|
+
// "actually warm" signal `ensureWarmForSweep` waits for — mark every
|
|
2527
|
+
// spawned server so a later sweep in this session sees the check as a
|
|
2528
|
+
// no-op instead of paying the warm-up round trip again.
|
|
2529
|
+
//
|
|
2530
|
+
// #743: the diagnostics wait is a blanket (touch-wide) gate, but the
|
|
2531
|
+
// notify-write timeout is now PER-SERVER — a healthy server whose sibling's
|
|
2532
|
+
// write stalled must still be eligible, so only servers whose OWN write
|
|
2533
|
+
// timed out are skipped here (rather than gating the whole loop on the
|
|
2534
|
+
// file-level `inconclusive`).
|
|
2535
|
+
//
|
|
2536
|
+
// #1470: same per-server reasoning for a CUT-OFF auxiliary. "Demonstrated
|
|
2537
|
+
// ready" means this server answered for this file; an auxiliary our grace
|
|
2538
|
+
// timer cut off demonstrably did not.
|
|
2539
|
+
//
|
|
2540
|
+
// NO TEST PINS THIS LINE, and that is a property of today's readers rather
|
|
2541
|
+
// than a coverage gap: `ensureWarmForSweep` filters `role === "auxiliary"`
|
|
2542
|
+
// out of its server list entirely, so no reader consumes an auxiliary's
|
|
2543
|
+
// `demonstratedReady` mark and deleting this `continue` changes no observable
|
|
2544
|
+
// behavior (verified by mutation — the LSP suite stays green). It stays
|
|
2545
|
+
// because the mark's meaning is "this server answered", and the moment any
|
|
2546
|
+
// reader stops filtering auxiliaries out, marking a cut-off scanner warm
|
|
2547
|
+
// would let it skip a warm-up it never earned.
|
|
2548
|
+
const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
|
|
2549
|
+
const cutOffServerIds = new Set(unconfirmedServerIds);
|
|
2550
|
+
if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
|
|
2551
|
+
for (const entry of spawned) {
|
|
2552
|
+
if (notifyTimedOutServerIds.has(entry.info.id))
|
|
2553
|
+
continue;
|
|
2554
|
+
if (cutOffServerIds.has(entry.info.id))
|
|
2555
|
+
continue;
|
|
2556
|
+
const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
|
|
2557
|
+
if (key)
|
|
2558
|
+
this.markDemonstratedReadyKey(key);
|
|
1667
2559
|
}
|
|
1668
2560
|
}
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
-
});
|
|
2561
|
+
// Prime the last-known cache WITH the hash of the content we just synced,
|
|
2562
|
+
// so a hot-path consumer (actionable-warnings at turn_end) can verify the
|
|
2563
|
+
// cached diagnostics are for the current bytes before reusing them instead
|
|
2564
|
+
// of paying for a second open+wait. Only when we actually collected — a
|
|
2565
|
+
// non-collecting touch (didChange-only) leaves the prior entry intact.
|
|
2566
|
+
// Skip this entirely when the touch was inconclusive: an unconfirmed
|
|
2567
|
+
// empty `collected` must never erase a previously-confirmed non-empty
|
|
2568
|
+
// record (that's the #570 bug — a timeout silently reporting as clean
|
|
2569
|
+
// and wiping out known-good diagnostic state).
|
|
2570
|
+
// #1470: a PARTIAL touch is the same hazard wearing a different flag. Its
|
|
2571
|
+
// merged array is missing whatever the cut-off auxiliary would have said, so
|
|
2572
|
+
// priming the cache with it would let `actionable-warnings`' hash-guarded
|
|
2573
|
+
// read replay a partially-covered result as an authoritative observation —
|
|
2574
|
+
// and an empty one would DELETE a previously-confirmed record on the strength
|
|
2575
|
+
// of a scanner that never answered. Skip the prime; the next read pays a real
|
|
2576
|
+
// round trip instead of trusting an incomplete one.
|
|
2577
|
+
if (collected !== undefined && !inconclusive && !coverageGap) {
|
|
2578
|
+
const normalizedKey = normalizeMapKey(filePath);
|
|
2579
|
+
if (collected.length > 0) {
|
|
2580
|
+
this.lastKnownDiagnostics.set(normalizedKey, collected);
|
|
2581
|
+
this.lastKnownContentHash.set(normalizedKey, this.hashContent(content));
|
|
2582
|
+
}
|
|
2583
|
+
else {
|
|
2584
|
+
this.lastKnownDiagnostics.delete(normalizedKey);
|
|
2585
|
+
this.lastKnownContentHash.delete(normalizedKey);
|
|
1731
2586
|
}
|
|
1732
2587
|
}
|
|
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));
|
|
2588
|
+
// #1179 (shape-5 structural fix): build the result WRAPPER. The two flags
|
|
2589
|
+
// that used to ride the returned array as NON-enumerable side-channels
|
|
2590
|
+
// (`Object.defineProperty(collected, ...)`, dropped by any `[...]`/`.filter`/
|
|
2591
|
+
// `JSON` copy — the #1094/#1096 loss class) are now EXPLICIT ENUMERABLE
|
|
2592
|
+
// fields on this wrapper. `.diags` holds the array a copy operates on, so the
|
|
2593
|
+
// flags survive by construction. Field presence mirrors the old attachment
|
|
2594
|
+
// conditions EXACTLY: `inconclusive` only for a confirmed-inconclusive
|
|
2595
|
+
// collect, `binding` only for a collecting touch — a non-collecting touch
|
|
2596
|
+
// keeps resolving `{ diags: [] }`, no flags.
|
|
2597
|
+
const result = { diags: collected ?? [] };
|
|
2598
|
+
if (collected !== undefined && inconclusive) {
|
|
2599
|
+
result.inconclusive = true;
|
|
1782
2600
|
}
|
|
1783
|
-
else {
|
|
1784
|
-
|
|
1785
|
-
|
|
2601
|
+
else if (collected !== undefined && coverageGap) {
|
|
2602
|
+
// #1470: narrowed, not collapsed. The primary's findings ride along in
|
|
2603
|
+
// `.diags` exactly as before; what changes is that the touch now states
|
|
2604
|
+
// which servers it does not speak for, so no consumer can read this as a
|
|
2605
|
+
// full clean bill of health.
|
|
2606
|
+
result.confirmation = "partial";
|
|
2607
|
+
result.unconfirmedServerIds = [...unconfirmedServerIds];
|
|
1786
2608
|
}
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
2609
|
+
else if (collected !== undefined) {
|
|
2610
|
+
// Preserve the lower-level affirmative result across consumers. In
|
|
2611
|
+
// particular, the silent-clean gates above clear diagnosticsTimedOut only
|
|
2612
|
+
// after a successful notify and capability-confirmed wait; reclassifying
|
|
2613
|
+
// that empty array later would discard the evidence that made it clean.
|
|
2614
|
+
result.confirmation = "confirmed";
|
|
2615
|
+
}
|
|
2616
|
+
// #1095: attach the merged content binding so a consumer can ask whether
|
|
2617
|
+
// these diagnostics were computed against current disk. Composed across every
|
|
2618
|
+
// spawned client so a single client whose view diverged from disk marks the
|
|
2619
|
+
// whole merged result mismatched. Disk verify is lazy + memoized per
|
|
2620
|
+
// (file, mtime).
|
|
2621
|
+
let binding;
|
|
2622
|
+
if (collected !== undefined) {
|
|
2623
|
+
binding = syncConfirmed
|
|
2624
|
+
? // #1095 (P3-b): a tsserver sync-confirmed result is authoritative for
|
|
2625
|
+
// the current buffer but not tied to the publish-path fingerprint —
|
|
2626
|
+
// surface "unknown" so a stale publish binding can't demote it.
|
|
2627
|
+
{ boundToCurrentDisk: "unknown" }
|
|
2628
|
+
: this.mergeBinding(filePath,
|
|
2629
|
+
// Optional-chain so a client without the getter (test doubles, a
|
|
2630
|
+
// partially-mocked client) yields "unknown" rather than throwing —
|
|
2631
|
+
// unknown preserves pre-#1095 behavior for that contributor.
|
|
2632
|
+
[
|
|
2633
|
+
...spawned.map((entry) => entry.client.getDiagnosticBinding?.(filePath)),
|
|
2634
|
+
...carriedAuxiliary.map((entry) => entry.binding),
|
|
2635
|
+
]);
|
|
2636
|
+
result.binding = binding;
|
|
2637
|
+
}
|
|
2638
|
+
// The recent-touches entries are recorded per server inside the notify-write
|
|
2639
|
+
// loop above, at the moment each server's own write lands (#1253) — a server
|
|
2640
|
+
// whose write timed out or rejected deliberately gets none, so the next touch
|
|
2641
|
+
// re-pushes it rather than debouncing a failure into a later touch that looks
|
|
2642
|
+
// fully delivered. Nothing to record here: a skipped server keeps its original
|
|
2643
|
+
// entry (and timestamp) so its window still expires naturally instead of being
|
|
2644
|
+
// extended by every reuse.
|
|
2645
|
+
logLatency({
|
|
2646
|
+
type: "phase",
|
|
2647
|
+
phase: "lsp_touch_file",
|
|
2648
|
+
filePath: normalizedPath,
|
|
2649
|
+
durationMs: Date.now() - startedAt,
|
|
2650
|
+
metadata: {
|
|
2651
|
+
serverCountReady: spawned.length,
|
|
2652
|
+
clientScope,
|
|
2653
|
+
diagnosticsMode,
|
|
2654
|
+
source,
|
|
2655
|
+
failureKind: "success",
|
|
2656
|
+
collectedDiagnostics: collected?.length,
|
|
2657
|
+
// #1095: observability so an unbinding server is diagnosable —
|
|
2658
|
+
// "bound" (matches disk) / "mismatch" (diverged) / "unknown"
|
|
2659
|
+
// (version-less server or disk unreadable). Absent for non-collecting
|
|
2660
|
+
// touches (no merged result to bind).
|
|
2661
|
+
...(binding !== undefined && {
|
|
2662
|
+
bindingState: bindingStateLabel(binding.boundToCurrentDisk),
|
|
2663
|
+
}),
|
|
2664
|
+
notifySkipped,
|
|
2665
|
+
notifyWriteTimedOut,
|
|
2666
|
+
// #743: per-server detail — which servers' writes actually timed out.
|
|
2667
|
+
// Absent when none did. `notifyWriteTimedOut` is the file-level "at
|
|
2668
|
+
// least one" summary.
|
|
2669
|
+
...(notifyWriteTimedOutServerIds.length > 0 && {
|
|
2670
|
+
notifyWriteTimedOutServerIds,
|
|
2671
|
+
}),
|
|
2672
|
+
diagnosticsTimedOut,
|
|
2673
|
+
inconclusive,
|
|
2674
|
+
// #1470: the touch's own honesty verdict, so a `cut_off` row in
|
|
2675
|
+
// `lsp_aux_wait_outcome` can be joined to the touch that produced it and
|
|
2676
|
+
// shown NOT to have claimed confirmation for that server's coverage.
|
|
2677
|
+
// Absent for a non-collecting touch, which claims nothing either way.
|
|
2678
|
+
...(result.confirmation !== undefined && {
|
|
2679
|
+
confirmation: result.confirmation,
|
|
2680
|
+
}),
|
|
2681
|
+
// R8 (#714): server ids of auxiliaries whose push wait was cut off by
|
|
2682
|
+
// the aux grace window (primary settled clean + aux timed out in grace).
|
|
2683
|
+
// Absent when no aux was cut off. These servers' diagnostics are
|
|
2684
|
+
// advisory-only and will surface on the next edit from their cache.
|
|
2685
|
+
...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
|
|
2686
|
+
},
|
|
1797
2687
|
});
|
|
2688
|
+
return result;
|
|
1798
2689
|
}
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
if (!notifySkipped) {
|
|
1803
|
-
this.markTouched(filePath, content, clientScope);
|
|
2690
|
+
finally {
|
|
2691
|
+
for (const key of leaseKeys)
|
|
2692
|
+
this.releaseClientLease(key);
|
|
1804
2693
|
}
|
|
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
2694
|
}
|
|
1836
2695
|
/**
|
|
1837
2696
|
* Get diagnostics for a file
|
|
@@ -1916,7 +2775,8 @@ export class LSPService {
|
|
|
1916
2775
|
}
|
|
1917
2776
|
const clientWaits = spawned.map(async (entry) => {
|
|
1918
2777
|
const waitStart = Date.now();
|
|
1919
|
-
const
|
|
2778
|
+
const launchVariant = entry.client.getLaunchVariant?.();
|
|
2779
|
+
const strategy = getStrategy(entry.info.id, launchVariant);
|
|
1920
2780
|
await entry.client.waitForDiagnostics(filePath, strategy.aggregateWaitMs);
|
|
1921
2781
|
let diagnostics = entry.client.getDiagnostics(filePath);
|
|
1922
2782
|
const firstWaitMs = Date.now() - waitStart;
|
|
@@ -1928,6 +2788,7 @@ export class LSPService {
|
|
|
1928
2788
|
}
|
|
1929
2789
|
return {
|
|
1930
2790
|
serverId: entry.info.id,
|
|
2791
|
+
launchVariant,
|
|
1931
2792
|
waitMs: Date.now() - waitStart,
|
|
1932
2793
|
diagnosticCount: diagnostics.length,
|
|
1933
2794
|
diagnostics,
|
|
@@ -1936,25 +2797,48 @@ export class LSPService {
|
|
|
1936
2797
|
// Document mode: 0ms grace — return as soon as any client has results.
|
|
1937
2798
|
// Full mode: 400ms grace — wait a bit for other clients to catch up.
|
|
1938
2799
|
const graceMs = diagnosticsMode === "document" ? 0 : EARLY_UNBLOCK_GRACE_MS;
|
|
1939
|
-
// R8 (#714): per-promise role descriptors so raceToCompletion
|
|
1940
|
-
// a bounded aux grace once all primary-role promises have
|
|
1941
|
-
// Servers with role:"auxiliary" (opengrep, ast-grep, zizmor, …)
|
|
1942
|
-
//
|
|
1943
|
-
//
|
|
2800
|
+
// R8 (#714) / #1458 S2: per-promise role descriptors so raceToCompletion
|
|
2801
|
+
// can apply a bounded aux grace once all primary-role promises have
|
|
2802
|
+
// settled. Servers with role:"auxiliary" (opengrep, ast-grep, zizmor, …)
|
|
2803
|
+
// get their OWN declared `aggregateWaitMs` budget after the primary
|
|
2804
|
+
// settles, capped by the PI_LENS_AUX_GRACE_MS global ceiling (default
|
|
2805
|
+
// 2000ms) — the same "declared budget, capped by a ceiling" shape
|
|
2806
|
+
// `touchFile`'s with-auxiliary push wait uses, so this lane can no longer
|
|
2807
|
+
// starve a scanner whose measured warm run (e.g. opengrep ~1.3s) is
|
|
2808
|
+
// shorter than the ceiling but longer than a flat short grace. Late
|
|
2809
|
+
// arrivals are still dropped (advisory only — they land in the client
|
|
1944
2810
|
// cache and surface on the next edit). Primary-only callers have no
|
|
1945
2811
|
// auxiliary descriptors, so this path is never entered and there is
|
|
1946
2812
|
// zero behavior change for the single-server hot path.
|
|
1947
|
-
const diagDescriptors = spawned.map((entry) =>
|
|
1948
|
-
|
|
1949
|
-
|
|
2813
|
+
const diagDescriptors = spawned.map((entry) => {
|
|
2814
|
+
if (entry.info.role !== "auxiliary")
|
|
2815
|
+
return { role: "primary" };
|
|
2816
|
+
const strategy = getStrategy(entry.info.id, entry.client.getLaunchVariant?.());
|
|
2817
|
+
// Deleting this `budgetMs` fails no end-to-end test, and that is
|
|
2818
|
+
// expected rather than a coverage gap: every promise in `clientWaits`
|
|
2819
|
+
// already self-bounds at this same `strategy.aggregateWaitMs`, so an
|
|
2820
|
+
// auxiliary cuts itself off at its declared budget whether or not the
|
|
2821
|
+
// shared grace timer also knows about it. The descriptor is what keeps
|
|
2822
|
+
// the two agreeing — it is the mitigation for the over-granting
|
|
2823
|
+
// `raceToCompletion` documents, and it starts mattering the moment a
|
|
2824
|
+
// promise here stops self-bounding. The narrowing itself is pinned in
|
|
2825
|
+
// tests/clients/lsp/aggregation.test.ts, which can build the
|
|
2826
|
+
// non-self-bounded promises this path cannot.
|
|
2827
|
+
return { role: "auxiliary", budgetMs: strategy.aggregateWaitMs };
|
|
2828
|
+
});
|
|
1950
2829
|
// Result-aware racing: trigger early-unblock when any client has results,
|
|
1951
2830
|
// OR when a seedFirstPush server returns (its first push is authoritative
|
|
1952
2831
|
// even when empty — waiting longer yields nothing more).
|
|
1953
|
-
const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 ||
|
|
1954
|
-
|
|
2832
|
+
const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 ||
|
|
2833
|
+
getStrategy(r.serverId, r.launchVariant).seedFirstPush), {
|
|
2834
|
+
timeoutMs: Math.max(...spawned.map((entry) => getStrategy(entry.info.id, entry.client.getLaunchVariant?.())
|
|
2835
|
+
.aggregateWaitMs)),
|
|
1955
2836
|
graceMs,
|
|
1956
2837
|
descriptors: diagDescriptors,
|
|
1957
|
-
|
|
2838
|
+
// #1458 S2: ceiling, not a flat wait — each auxiliary's own
|
|
2839
|
+
// budgetMs (above) determines the actual per-touch grace up to
|
|
2840
|
+
// this cap. Matches touchFile's `readEnvAuxGraceMs() ?? 2000`.
|
|
2841
|
+
auxGraceMs: readEnvAuxGraceMs() ?? 2000,
|
|
1958
2842
|
});
|
|
1959
2843
|
// Fill in any slots that timed out before producing results.
|
|
1960
2844
|
const earlyUnblockedCount = spawned.length - perServer.length;
|
|
@@ -1962,7 +2846,8 @@ export class LSPService {
|
|
|
1962
2846
|
const found = perServer.find((r) => r.serverId === entry.info.id);
|
|
1963
2847
|
return (found ?? {
|
|
1964
2848
|
serverId: entry.info.id,
|
|
1965
|
-
|
|
2849
|
+
launchVariant: entry.client.getLaunchVariant?.(),
|
|
2850
|
+
waitMs: getStrategy(entry.info.id, entry.client.getLaunchVariant?.()).aggregateWaitMs,
|
|
1966
2851
|
diagnosticCount: 0,
|
|
1967
2852
|
diagnostics: [],
|
|
1968
2853
|
});
|
|
@@ -2036,6 +2921,40 @@ export class LSPService {
|
|
|
2036
2921
|
hashContent(content) {
|
|
2037
2922
|
return createHash("sha256").update(content).digest("hex");
|
|
2038
2923
|
}
|
|
2924
|
+
/**
|
|
2925
|
+
* #1095: compose the content `binding` for a merged diagnostics result across
|
|
2926
|
+
* the clients that contributed to it (primary + any auxiliaries). Verifies
|
|
2927
|
+
* each contributor's stored binding against current disk lazily (memoized per
|
|
2928
|
+
* file+mtime by {@link diskBindingCache}) and composes per
|
|
2929
|
+
* {@link composeBoundToCurrentDisk}: ANY contributor mismatches disk → false;
|
|
2930
|
+
* else all "unknown" (or no contributors) → "unknown"; else true. The
|
|
2931
|
+
* surfaced version/contentHash come from the first contributor that carries
|
|
2932
|
+
* them — informational only; the load-bearing field is `boundToCurrentDisk`.
|
|
2933
|
+
* #1104: `version` and `contentHash` are tracked INDEPENDENTLY (not gated on
|
|
2934
|
+
* the same contributor carrying both) — a pull-sourced binding deliberately
|
|
2935
|
+
* has no `version` (the pull protocol has no version-echo concept) but does
|
|
2936
|
+
* carry a real `contentHash`; gating the latter on the former would silently
|
|
2937
|
+
* drop it here even though `boundToCurrentDisk` above already used it.
|
|
2938
|
+
*/
|
|
2939
|
+
mergeBinding(filePath, stored) {
|
|
2940
|
+
const verdicts = [];
|
|
2941
|
+
let version;
|
|
2942
|
+
let contentHash;
|
|
2943
|
+
for (const entry of stored) {
|
|
2944
|
+
verdicts.push(this.diskBindingCache.boundToCurrentDisk(filePath, entry ?? {}));
|
|
2945
|
+
if (version === undefined && entry?.version !== undefined) {
|
|
2946
|
+
version = entry.version;
|
|
2947
|
+
}
|
|
2948
|
+
if (contentHash === undefined && entry?.contentHash !== undefined) {
|
|
2949
|
+
contentHash = entry.contentHash;
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
return {
|
|
2953
|
+
version,
|
|
2954
|
+
contentHash,
|
|
2955
|
+
boundToCurrentDisk: composeBoundToCurrentDisk(verdicts),
|
|
2956
|
+
};
|
|
2957
|
+
}
|
|
2039
2958
|
/**
|
|
2040
2959
|
* Navigation: go to definition
|
|
2041
2960
|
*/
|
|
@@ -2134,18 +3053,18 @@ export class LSPService {
|
|
|
2134
3053
|
* advertisement in the client). If filePath is given, target that file's
|
|
2135
3054
|
* server; otherwise the first active client.
|
|
2136
3055
|
*/
|
|
2137
|
-
async executeCommand(filePath, command, args) {
|
|
3056
|
+
async executeCommand(filePath, command, args, mutationContext) {
|
|
2138
3057
|
if (filePath) {
|
|
2139
3058
|
const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
|
|
2140
3059
|
if (!spawned) {
|
|
2141
3060
|
return { executed: false, reason: "no LSP server for file" };
|
|
2142
3061
|
}
|
|
2143
|
-
return spawned.client.executeCommand(command, args);
|
|
3062
|
+
return spawned.client.executeCommand(command, args, mutationContext);
|
|
2144
3063
|
}
|
|
2145
3064
|
const first = this.state.clients.values().next().value;
|
|
2146
3065
|
if (!first)
|
|
2147
3066
|
return { executed: false, reason: "no active LSP server" };
|
|
2148
|
-
return first.executeCommand(command, args);
|
|
3067
|
+
return first.executeCommand(command, args, mutationContext);
|
|
2149
3068
|
}
|
|
2150
3069
|
/**
|
|
2151
3070
|
* Capability snapshot for LSP operations.
|
|
@@ -2180,7 +3099,7 @@ export class LSPService {
|
|
|
2180
3099
|
if (filePath) {
|
|
2181
3100
|
const servers = getServersForFileWithConfig(filePath);
|
|
2182
3101
|
for (const server of servers) {
|
|
2183
|
-
const root = await
|
|
3102
|
+
const root = await this.resolveServerRoot(server, filePath);
|
|
2184
3103
|
if (!root)
|
|
2185
3104
|
continue;
|
|
2186
3105
|
const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
|
|
@@ -2254,6 +3173,20 @@ export class LSPService {
|
|
|
2254
3173
|
async renameFile(oldFilePath, newFilePath, options) {
|
|
2255
3174
|
const cwd = options.cwd;
|
|
2256
3175
|
const apply = options.apply ?? false;
|
|
3176
|
+
// Validate the complete resource operation before asking any server for
|
|
3177
|
+
// willRenameFiles edits. This is a read-only preflight, but it reuses the
|
|
3178
|
+
// same confinement, realpath/symlink, existence, and destination checks as
|
|
3179
|
+
// the eventual apply path, so an invalid rename cannot first mutate an
|
|
3180
|
+
// in-workspace file through returned text edits (including previews).
|
|
3181
|
+
await validateWorkspaceEdit({
|
|
3182
|
+
documentChanges: [
|
|
3183
|
+
{
|
|
3184
|
+
kind: "rename",
|
|
3185
|
+
oldUri: pathToFileURL(oldFilePath).href,
|
|
3186
|
+
newUri: pathToFileURL(newFilePath).href,
|
|
3187
|
+
},
|
|
3188
|
+
],
|
|
3189
|
+
}, cwd);
|
|
2257
3190
|
const priorityServerIds = getServersForFileWithConfig(oldFilePath).map((server) => server.id);
|
|
2258
3191
|
const activeClients = this.activeClientsForCwd(cwd, priorityServerIds);
|
|
2259
3192
|
const willRenameFailures = [];
|
|
@@ -2290,25 +3223,142 @@ export class LSPService {
|
|
|
2290
3223
|
summary,
|
|
2291
3224
|
};
|
|
2292
3225
|
}
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
3226
|
+
let applied;
|
|
3227
|
+
try {
|
|
3228
|
+
applied = await applyWorkspaceEdit(merged.edit, cwd, {
|
|
3229
|
+
mutationContext: options.mutationContext,
|
|
3230
|
+
observe: false,
|
|
3231
|
+
});
|
|
3232
|
+
}
|
|
3233
|
+
catch (err) {
|
|
3234
|
+
if (options.mutationContext) {
|
|
3235
|
+
const partial = err
|
|
3236
|
+
.appliedWorkspaceEdit;
|
|
3237
|
+
if (partial) {
|
|
3238
|
+
recordLspMutation(options.mutationContext, {
|
|
3239
|
+
results: [partial],
|
|
3240
|
+
status: "failed",
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
throw err;
|
|
3245
|
+
}
|
|
3246
|
+
const openDocuments = activeClients
|
|
3247
|
+
.filter(({ client }) => client.isDocumentOpen(oldFilePath))
|
|
3248
|
+
.map(({ serverId, client }) => ({
|
|
3249
|
+
serverId,
|
|
3250
|
+
client,
|
|
3251
|
+
oldUri: client.getDocumentUri(oldFilePath),
|
|
3252
|
+
}));
|
|
3253
|
+
const closeFailures = [];
|
|
3254
|
+
await Promise.all(openDocuments.map(async ({ serverId, client }) => {
|
|
3255
|
+
try {
|
|
3256
|
+
await client.closeDocument(oldFilePath);
|
|
3257
|
+
return undefined;
|
|
3258
|
+
}
|
|
3259
|
+
catch (err) {
|
|
3260
|
+
closeFailures.push({
|
|
3261
|
+
serverId,
|
|
3262
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3263
|
+
});
|
|
3264
|
+
return undefined;
|
|
3265
|
+
}
|
|
3266
|
+
}));
|
|
3267
|
+
if (closeFailures.length > 0) {
|
|
3268
|
+
if (options.mutationContext) {
|
|
3269
|
+
recordLspMutation(options.mutationContext, {
|
|
3270
|
+
results: [applied],
|
|
3271
|
+
status: "failed",
|
|
3272
|
+
});
|
|
3273
|
+
}
|
|
3274
|
+
// Do not rename or send didRenameFiles while any server still has the
|
|
3275
|
+
// old document open. Re-open/resync every affected client so a partial
|
|
3276
|
+
// close cannot leave an in-memory document behind the disk contents.
|
|
3277
|
+
// Reopen with the document's ACTUAL language ID (the same resolver every
|
|
3278
|
+
// genuine open uses) rather than a hardcoded "plaintext" fallback — a
|
|
3279
|
+
// wrong languageId here degrades that server's diagnostics until the
|
|
3280
|
+
// next genuine open (#1147 P3-7).
|
|
3281
|
+
const content = await fs.readFile(oldFilePath, "utf-8");
|
|
3282
|
+
const languageId = getLanguageId(oldFilePath) ?? "plaintext";
|
|
3283
|
+
await Promise.all(openDocuments.map(({ client }) => client.notify.open(oldFilePath, content, languageId, true, true)));
|
|
3284
|
+
throw new Error(`workspace/didClose failed; rename aborted: ${closeFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
|
|
3285
|
+
}
|
|
3286
|
+
let renameApplied;
|
|
3287
|
+
try {
|
|
3288
|
+
// Route the resource mutation through the same preflight confinement,
|
|
3289
|
+
// realpath and symlink policy as all other workspace edits. Do not
|
|
3290
|
+
// duplicate that security boundary with a direct mkdir/rename pair.
|
|
3291
|
+
renameApplied = await applyWorkspaceEdit({
|
|
3292
|
+
documentChanges: [
|
|
3293
|
+
{
|
|
3294
|
+
kind: "rename",
|
|
3295
|
+
oldUri: pathToFileURL(oldFilePath).href,
|
|
3296
|
+
newUri: pathToFileURL(newFilePath).href,
|
|
3297
|
+
},
|
|
3298
|
+
],
|
|
3299
|
+
}, cwd, { observe: false });
|
|
3300
|
+
}
|
|
3301
|
+
catch (err) {
|
|
3302
|
+
if (options.mutationContext) {
|
|
3303
|
+
recordLspMutation(options.mutationContext, {
|
|
3304
|
+
results: [applied],
|
|
3305
|
+
status: "failed",
|
|
3306
|
+
});
|
|
3307
|
+
}
|
|
3308
|
+
throw err;
|
|
3309
|
+
}
|
|
2296
3310
|
const relOld = path.relative(cwd, oldFilePath).replace(/\\/g, "/") ||
|
|
2297
3311
|
path.basename(oldFilePath);
|
|
2298
3312
|
const relNew = path.relative(cwd, newFilePath).replace(/\\/g, "/") ||
|
|
2299
3313
|
path.basename(newFilePath);
|
|
2300
3314
|
const renameDescription = `Renamed ${relOld} → ${relNew}`;
|
|
2301
3315
|
await Promise.all(activeClients.map(async ({ serverId, client }) => {
|
|
3316
|
+
const opened = openDocuments.find((entry) => entry.serverId === serverId);
|
|
2302
3317
|
try {
|
|
2303
|
-
|
|
3318
|
+
if (opened?.oldUri) {
|
|
3319
|
+
await client.didRenameFiles(oldFilePath, newFilePath, opened.oldUri, destinationUriPreservingSpelling(opened.oldUri, oldFilePath, newFilePath));
|
|
3320
|
+
}
|
|
3321
|
+
else {
|
|
3322
|
+
await client.didRenameFiles(oldFilePath, newFilePath);
|
|
3323
|
+
}
|
|
3324
|
+
return undefined;
|
|
2304
3325
|
}
|
|
2305
3326
|
catch (err) {
|
|
2306
3327
|
didRenameFailures.push({
|
|
2307
3328
|
serverId,
|
|
2308
3329
|
error: err instanceof Error ? err.message : String(err),
|
|
2309
3330
|
});
|
|
3331
|
+
return undefined;
|
|
2310
3332
|
}
|
|
2311
3333
|
}));
|
|
3334
|
+
const files = [...new Set([...applied.files, oldFilePath, newFilePath])];
|
|
3335
|
+
if (options.mutationContext) {
|
|
3336
|
+
recordLspMutation(options.mutationContext, {
|
|
3337
|
+
results: [
|
|
3338
|
+
{
|
|
3339
|
+
...applied,
|
|
3340
|
+
files,
|
|
3341
|
+
operationTotal: applied.operationTotal + renameApplied.operationTotal,
|
|
3342
|
+
appliedOperationTotal: applied.appliedOperationTotal + renameApplied.appliedOperationTotal,
|
|
3343
|
+
appliedOperationIndexes: [
|
|
3344
|
+
...applied.appliedOperationIndexes,
|
|
3345
|
+
...renameApplied.appliedOperationIndexes.map((index) => applied.operationTotal + index),
|
|
3346
|
+
],
|
|
3347
|
+
operationCounts: {
|
|
3348
|
+
textEdits: applied.operationCounts.textEdits + renameApplied.operationCounts.textEdits,
|
|
3349
|
+
create: applied.operationCounts.create + renameApplied.operationCounts.create,
|
|
3350
|
+
rename: applied.operationCounts.rename + renameApplied.operationCounts.rename,
|
|
3351
|
+
delete: applied.operationCounts.delete + renameApplied.operationCounts.delete,
|
|
3352
|
+
},
|
|
3353
|
+
fileDetails: [
|
|
3354
|
+
...applied.fileDetails,
|
|
3355
|
+
...renameApplied.fileDetails,
|
|
3356
|
+
],
|
|
3357
|
+
},
|
|
3358
|
+
],
|
|
3359
|
+
status: "success",
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
2312
3362
|
return {
|
|
2313
3363
|
applied: true,
|
|
2314
3364
|
serverIds: activeClients.map((entry) => entry.serverId),
|
|
@@ -2318,7 +3368,7 @@ export class LSPService {
|
|
|
2318
3368
|
inputEditCount: merged.inputEditCount,
|
|
2319
3369
|
summary,
|
|
2320
3370
|
descriptions: [...applied.descriptions, renameDescription],
|
|
2321
|
-
files
|
|
3371
|
+
files,
|
|
2322
3372
|
};
|
|
2323
3373
|
}
|
|
2324
3374
|
/**
|
|
@@ -2556,16 +3606,21 @@ export class LSPService {
|
|
|
2556
3606
|
ms: timeoutMs,
|
|
2557
3607
|
onTimeout: "undefined",
|
|
2558
3608
|
}));
|
|
3609
|
+
const coldServerIds = stillColdServerIds();
|
|
2559
3610
|
logLatency({
|
|
2560
3611
|
type: "phase",
|
|
2561
|
-
phase:
|
|
3612
|
+
phase: options.signal?.aborted
|
|
3613
|
+
? "lsp_sweep_warmup_aborted"
|
|
3614
|
+
: coldServerIds.length > 0
|
|
3615
|
+
? "lsp_sweep_warmup_failed"
|
|
3616
|
+
: "lsp_sweep_warmup_done",
|
|
2562
3617
|
filePath: representativeFile,
|
|
2563
3618
|
durationMs: Date.now() - startedAt,
|
|
2564
3619
|
metadata: {
|
|
2565
3620
|
serverIds: servers.map((s) => s.id),
|
|
2566
3621
|
timeoutMs,
|
|
2567
3622
|
attempt,
|
|
2568
|
-
coldServerIds
|
|
3623
|
+
coldServerIds,
|
|
2569
3624
|
},
|
|
2570
3625
|
});
|
|
2571
3626
|
};
|
|
@@ -2591,13 +3646,6 @@ export class LSPService {
|
|
|
2591
3646
|
}
|
|
2592
3647
|
}
|
|
2593
3648
|
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
3649
|
// #799: record the negative cache so a LATER sweep this session
|
|
2602
3650
|
// skips straight past re-paying this warm-up (initial + retry).
|
|
2603
3651
|
// Cleared automatically the moment the server demonstrates
|
|
@@ -2659,13 +3707,31 @@ export class LSPService {
|
|
|
2659
3707
|
]);
|
|
2660
3708
|
const cachedResults = [];
|
|
2661
3709
|
const filesToTouch = [];
|
|
3710
|
+
const writeIndexByPath = new Map();
|
|
2662
3711
|
for (const filePath of files) {
|
|
3712
|
+
// Reserve order before cache lookup/touch admission. A result that settles
|
|
3713
|
+
// later is still ordered by when this scan began observing the file (#1198).
|
|
3714
|
+
writeIndexByPath.set(normalizeMapKey(filePath), options.nextWriteIndex?.());
|
|
2663
3715
|
const cached = workspaceDiagnosticsCacheCtx.lookup(filePath, workspaceSweepScopeKey);
|
|
2664
|
-
|
|
3716
|
+
// #1095 (P2-1 bug-class sweep): both sweeps share cache entries under the
|
|
3717
|
+
// same scopeKey, so this service sweep must apply the SAME content-binding
|
|
3718
|
+
// gate the tools/lsp-diagnostics.ts sibling site does — an entry whose
|
|
3719
|
+
// recorded fingerprint no longer matches disk (bytes changed WITHOUT an
|
|
3720
|
+
// mtime bump the freshness check would catch, exactly what contentHash
|
|
3721
|
+
// exists to detect) must NOT be replayed and reconciled as confirmed via
|
|
3722
|
+
// mode=full. On a mismatch, fall through to a fresh touch.
|
|
3723
|
+
if (cached && cached.binding.boundToCurrentDisk !== false) {
|
|
2665
3724
|
cachedResults.push({
|
|
2666
3725
|
filePath,
|
|
2667
3726
|
diagnostics: cached.diagnostics,
|
|
2668
3727
|
count: cached.count,
|
|
3728
|
+
// #1093: a cache hit replays an older observation — carry its
|
|
3729
|
+
// scan time so mode=full's footer reconcile stamps `touchedAt`
|
|
3730
|
+
// with when the truth was seen, not now().
|
|
3731
|
+
observedAt: cached.scannedAt,
|
|
3732
|
+
contentHash: cached.binding.contentHash,
|
|
3733
|
+
boundToCurrentDisk: cached.binding.boundToCurrentDisk,
|
|
3734
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
2669
3735
|
});
|
|
2670
3736
|
}
|
|
2671
3737
|
else {
|
|
@@ -2847,8 +3913,26 @@ export class LSPService {
|
|
|
2847
3913
|
if (attached && !attached.available) {
|
|
2848
3914
|
await this.ensureWarmForSweep(filePath, { signal });
|
|
2849
3915
|
}
|
|
2850
|
-
|
|
2851
|
-
|
|
3916
|
+
// #1179 (shape-5 structural fix): normalize both sources into the
|
|
3917
|
+
// `TouchFileResult` wrapper shape so the flag reads below come off
|
|
3918
|
+
// EXPLICIT fields, not a non-enumerable side-channel a copy would drop.
|
|
3919
|
+
// The warm-attach IPC branch resolves a plain diagnostics array with
|
|
3920
|
+
// `inconclusive` re-surfaced as an enumerable DTO field (the socket can
|
|
3921
|
+
// carry no binding, and the IPC client already rejects an inconclusive
|
|
3922
|
+
// answer, so `available` implies confirmed) — wrap it as `{ diags }`;
|
|
3923
|
+
// the incumbent branch already returns the wrapper.
|
|
3924
|
+
const touchResult = attached?.available
|
|
3925
|
+
? {
|
|
3926
|
+
diags: attached.response.diagnostics,
|
|
3927
|
+
// #1470: the incumbent's coverage gap crosses the socket as an
|
|
3928
|
+
// explicit DTO field, so carry it onto the wrapper the sweep
|
|
3929
|
+
// reads. Dropping it here would let a partially covered
|
|
3930
|
+
// incumbent answer be persisted as a confirmed sweep result —
|
|
3931
|
+
// the same false clean this change closes on the local route.
|
|
3932
|
+
...(attached.response.unconfirmedServerIds !== undefined && {
|
|
3933
|
+
unconfirmedServerIds: attached.response.unconfirmedServerIds,
|
|
3934
|
+
}),
|
|
3935
|
+
}
|
|
2852
3936
|
: await withDeadline(this.touchFile(filePath, content, {
|
|
2853
3937
|
diagnostics: "document",
|
|
2854
3938
|
collectDiagnostics: true,
|
|
@@ -2863,17 +3947,34 @@ export class LSPService {
|
|
|
2863
3947
|
// full wait budget only once across this whole sweep.
|
|
2864
3948
|
sweepIndexGate,
|
|
2865
3949
|
}), { ms: perFileMs, onTimeout: "undefined" });
|
|
3950
|
+
const diagnostics = touchResult?.diags;
|
|
2866
3951
|
// #571: prefer #570's real per-touch inconclusive signal
|
|
2867
|
-
// (`touchFile`'s
|
|
2868
|
-
//
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
3952
|
+
// (`touchFile`'s `.inconclusive` flag — set when the notify write or the
|
|
3953
|
+
// diagnostics wait itself timed out) over this sweep's own OUTER
|
|
3954
|
+
// `perFileMs` deadline, which only catches a touch that never returned at
|
|
3955
|
+
// all within budget. Either one means the result wasn't confirmed.
|
|
3956
|
+
const inconclusive = touchResult?.inconclusive === true;
|
|
3957
|
+
// #1470: a cut-off auxiliary is the THIRD reason this result is not a
|
|
3958
|
+
// confirmed observation, and it is deliberately not `inconclusive`. The
|
|
3959
|
+
// record loop below persists every `!timedOut` result into the workspace
|
|
3960
|
+
// cache, so reading `inconclusive` alone caches a partially covered
|
|
3961
|
+
// answer as clean and replays it on every later sweep. Today the only
|
|
3962
|
+
// route that can reach this branch with a gap is the warm-attach
|
|
3963
|
+
// incumbent, whose touch runs `clientScope: "with-auxiliary"`; the
|
|
3964
|
+
// sweep's own local touch uses `clientScope: "all"`, which never arms
|
|
3965
|
+
// the aux grace timer at all. Both are gated here so a future scope
|
|
3966
|
+
// change cannot reopen the hole silently.
|
|
3967
|
+
const coverageGap = touchCoverageGap(touchResult).length > 0;
|
|
3968
|
+
const timedOut = touchResult === undefined || inconclusive || coverageGap;
|
|
2875
3969
|
if (timedOut)
|
|
2876
3970
|
timedOutFiles += 1;
|
|
3971
|
+
// #1104 (shape 5 — AGENTS.md): the touch's content binding is an
|
|
3972
|
+
// EXPLICIT enumerable field on the wrapper now (#1179), so it survives
|
|
3973
|
+
// `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
|
|
3974
|
+
// — read it off `touchResult`, not off the derived array. A version-
|
|
3975
|
+
// less/pull-less server composed no binding, so `contentHash` stays
|
|
3976
|
+
// undefined here (honest "unknown" downstream), matching prior behavior.
|
|
3977
|
+
const rawBinding = touchResult?.binding;
|
|
2877
3978
|
// #586: honor each auxiliary profile's native inline-suppression
|
|
2878
3979
|
// comment (e.g. opengrep's `// nosemgrep`, #441) — computed from the
|
|
2879
3980
|
// raw `diagnostics` (before this drops its non-enumerable
|
|
@@ -2897,6 +3998,9 @@ export class LSPService {
|
|
|
2897
3998
|
diagnostics: filteredDiagnostics ?? [],
|
|
2898
3999
|
count: filteredDiagnostics?.length ?? 0,
|
|
2899
4000
|
timedOut,
|
|
4001
|
+
contentHash: rawBinding?.contentHash,
|
|
4002
|
+
boundToCurrentDisk: rawBinding?.boundToCurrentDisk,
|
|
4003
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
2900
4004
|
});
|
|
2901
4005
|
}
|
|
2902
4006
|
catch (err) {
|
|
@@ -2909,6 +4013,7 @@ export class LSPService {
|
|
|
2909
4013
|
// no confirmed result was obtained, so reconciliation (#571) must
|
|
2910
4014
|
// skip it the same way.
|
|
2911
4015
|
timedOut: true,
|
|
4016
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
|
|
2912
4017
|
});
|
|
2913
4018
|
}
|
|
2914
4019
|
completed += 1;
|
|
@@ -2948,7 +4053,10 @@ export class LSPService {
|
|
|
2948
4053
|
const pulled = await this.tryWorkspacePull(group.files, perFileMs);
|
|
2949
4054
|
if (pulled) {
|
|
2950
4055
|
for (const result of pulled) {
|
|
2951
|
-
results.push(
|
|
4056
|
+
results.push({
|
|
4057
|
+
...result,
|
|
4058
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
|
|
4059
|
+
});
|
|
2952
4060
|
// #671: a pull result is always confirmed (see
|
|
2953
4061
|
// `tryWorkspacePull`'s doc comment), so it's cache-eligible
|
|
2954
4062
|
// too — best-effort stat since the pull already resolved the
|
|
@@ -3072,7 +4180,15 @@ export class LSPService {
|
|
|
3072
4180
|
const scannedAt = scannedMtimeByFile.get(result.filePath);
|
|
3073
4181
|
if (result.error || result.timedOut || scannedAt === undefined)
|
|
3074
4182
|
continue;
|
|
3075
|
-
|
|
4183
|
+
// #1104: thread the per-result `contentHash` (from either the
|
|
4184
|
+
// `tryWorkspacePull` fast path or a per-file touch's own #1095 binding)
|
|
4185
|
+
// into the cache entry — previously this call never passed one, so
|
|
4186
|
+
// every pull-served entry read binding "unknown" forever and the #1095
|
|
4187
|
+
// P2-1 service-sweep binding gate above (`cached.binding.
|
|
4188
|
+
// boundToCurrentDisk !== false`) could never demote a pull-cached
|
|
4189
|
+
// entry. Absent `contentHash` (no binding was available) behaves
|
|
4190
|
+
// exactly as before.
|
|
4191
|
+
workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
|
|
3076
4192
|
}
|
|
3077
4193
|
workspaceDiagnosticsCacheCtx.persist();
|
|
3078
4194
|
return [...cachedResults, ...results].filter(Boolean);
|
|
@@ -3099,14 +4215,24 @@ export class LSPService {
|
|
|
3099
4215
|
return undefined;
|
|
3100
4216
|
const byPath = new Map();
|
|
3101
4217
|
for (const entry of report) {
|
|
3102
|
-
byPath.set(normalizeMapKey(entry.filePath), entry
|
|
4218
|
+
byPath.set(normalizeMapKey(entry.filePath), entry);
|
|
3103
4219
|
}
|
|
3104
4220
|
return groupFiles.map((filePath) => {
|
|
3105
|
-
const
|
|
4221
|
+
const entry = byPath.get(normalizeMapKey(filePath));
|
|
4222
|
+
const diagnostics = entry?.diagnostics ?? [];
|
|
3106
4223
|
// A pull that got here returned a real workspace/diagnostic report
|
|
3107
4224
|
// (see the `!report` guard above) — always confirmed, unlike a
|
|
3108
4225
|
// per-file touchFile default-empty on timeout.
|
|
3109
|
-
|
|
4226
|
+
// #1104: `contentHash` comes straight from the client's pull layer —
|
|
4227
|
+
// a file absent from the report (reported "clean" here) carries none,
|
|
4228
|
+
// same honest "unknown" the record site already applies for it.
|
|
4229
|
+
return {
|
|
4230
|
+
filePath,
|
|
4231
|
+
diagnostics,
|
|
4232
|
+
count: diagnostics.length,
|
|
4233
|
+
timedOut: false,
|
|
4234
|
+
contentHash: entry?.contentHash,
|
|
4235
|
+
};
|
|
3110
4236
|
});
|
|
3111
4237
|
}
|
|
3112
4238
|
catch {
|
|
@@ -3118,6 +4244,9 @@ export class LSPService {
|
|
|
3118
4244
|
*/
|
|
3119
4245
|
async getAllDiagnostics() {
|
|
3120
4246
|
const all = new Map();
|
|
4247
|
+
// #1095: per-file stored bindings across every contributing client, merged
|
|
4248
|
+
// into one `boundToCurrentDisk` verdict after the client loop.
|
|
4249
|
+
const bindingsByPath = new Map();
|
|
3121
4250
|
const now = Date.now();
|
|
3122
4251
|
for (const [_key, client] of this.state.clients) {
|
|
3123
4252
|
// Resolve existence asynchronously (was a blocking existsSync per tracked
|
|
@@ -3150,8 +4279,35 @@ export class LSPService {
|
|
|
3150
4279
|
else {
|
|
3151
4280
|
all.set(filePath, { diags: [...entry.diags], ts: entry.ts });
|
|
3152
4281
|
}
|
|
4282
|
+
const list = bindingsByPath.get(filePath) ?? [];
|
|
4283
|
+
list.push(entry.binding);
|
|
4284
|
+
bindingsByPath.set(filePath, list);
|
|
3153
4285
|
}
|
|
3154
4286
|
}
|
|
4287
|
+
// #1095 (P2-2): expose the composed content binding per file LAZILY — the
|
|
4288
|
+
// disk stat+hash verify only runs when a consumer actually reads `.binding`.
|
|
4289
|
+
// The current caller (the cascade, once per edit turn) does NOT read it yet
|
|
4290
|
+
// (binding adoption in cascade/integration.ts is deferred to the second PR),
|
|
4291
|
+
// so this leaves ZERO eager disk cost while still surfacing the field for the
|
|
4292
|
+
// consumer that arrives next. Non-enumerable so incidental spread/serialize
|
|
4293
|
+
// (e.g. logging) can't trigger a stat storm; memoized per entry so repeated
|
|
4294
|
+
// reads verify once.
|
|
4295
|
+
// #1179: DELIBERATELY NOT migrated to an enumerable wrapper field (unlike the
|
|
4296
|
+
// `touchFile` flags). Enumerable would make an incidental `{...entry}`/serialize
|
|
4297
|
+
// eagerly fire this getter and stat every file — the exact stat storm the
|
|
4298
|
+
// laziness above exists to prevent. It therefore stays on the read-off-original
|
|
4299
|
+
// carriage contract (see `DiagnosticBinding`): every consumer reads `.binding`
|
|
4300
|
+
// off the ORIGINAL Map entry (`clients/dispatch/integration.ts` reads it off
|
|
4301
|
+
// `entry`, never a copy), and the cascade's TTL gate reads it only when fresh.
|
|
4302
|
+
for (const [filePath, entry] of all) {
|
|
4303
|
+
const stored = bindingsByPath.get(filePath) ?? [];
|
|
4304
|
+
let memo;
|
|
4305
|
+
Object.defineProperty(entry, "binding", {
|
|
4306
|
+
enumerable: false,
|
|
4307
|
+
configurable: true,
|
|
4308
|
+
get: () => (memo ??= this.mergeBinding(filePath, stored)),
|
|
4309
|
+
});
|
|
4310
|
+
}
|
|
3155
4311
|
return all;
|
|
3156
4312
|
}
|
|
3157
4313
|
/**
|
|
@@ -3186,6 +4342,9 @@ export class LSPService {
|
|
|
3186
4342
|
if (this.checkDestroyed())
|
|
3187
4343
|
return;
|
|
3188
4344
|
this.isDestroyed = true;
|
|
4345
|
+
for (const key of this.typeScriptIdleTimers.keys()) {
|
|
4346
|
+
this.clearTypeScriptIdleTimer(key);
|
|
4347
|
+
}
|
|
3189
4348
|
// Belt-and-braces: wait for any in-flight spawns so that Guard 1/2 in
|
|
3190
4349
|
// spawnClient can observe isDestroyed and clean up. Skip on the
|
|
3191
4350
|
// process-exiting path — the event loop is closing and we must not block.
|
|
@@ -3237,12 +4396,69 @@ export class LSPService {
|
|
|
3237
4396
|
getStatus() {
|
|
3238
4397
|
return Array.from(this.state.clients.entries()).map(([key, client]) => {
|
|
3239
4398
|
const [serverId, root] = key.split(":");
|
|
3240
|
-
return {
|
|
4399
|
+
return {
|
|
4400
|
+
serverId,
|
|
4401
|
+
root,
|
|
4402
|
+
connected: client.isAlive(),
|
|
4403
|
+
pullFailureHistory: (client.getPullFailureHistory?.() ?? []).map((entry) => ({
|
|
4404
|
+
...entry,
|
|
4405
|
+
message: entry.message.slice(0, 200),
|
|
4406
|
+
})),
|
|
4407
|
+
};
|
|
3241
4408
|
});
|
|
3242
4409
|
}
|
|
4410
|
+
/**
|
|
4411
|
+
* #1142 windowed-rate breaker (composes with the {@link runtimeExitCounts}
|
|
4412
|
+
* fast path — see that field and {@link RUNTIME_EXIT_WINDOW_MS}).
|
|
4413
|
+
*
|
|
4414
|
+
* Records one non-intentional death and returns true iff this death pushes
|
|
4415
|
+
* the rolling window to {@link RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths, i.e.
|
|
4416
|
+
* the breaker should give up now. The caller has ALREADY excluded intentional
|
|
4417
|
+
* teardowns (shutdown()-driven restarts, #743 eviction, session resets,
|
|
4418
|
+
* generation handoffs) — only genuine crash-respawns reach here.
|
|
4419
|
+
*
|
|
4420
|
+
* Sleep-gap / long-healthy-run guard: a death whose measured lifetime exceeds
|
|
4421
|
+
* {@link RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS} (or is unknown) is NOT
|
|
4422
|
+
* recorded — a lifetime spanning a Modern-Standby suspend, or a genuinely
|
|
4423
|
+
* long run that crashed once, is not churn. See that constant for the
|
|
4424
|
+
* per-KEY structural defense this backs up.
|
|
4425
|
+
*
|
|
4426
|
+
* Bounded on both axes (defect shape 9): prune entries outside the window,
|
|
4427
|
+
* then hard-cap the retained array at the trip count (drop-oldest — a rate
|
|
4428
|
+
* trip needs only the in-window count, so earliest-evidence order is
|
|
4429
|
+
* irrelevant here).
|
|
4430
|
+
*/
|
|
4431
|
+
recordRuntimeExitWindow(key, exitedAt, uptimeMs) {
|
|
4432
|
+
if (exitedAt == null ||
|
|
4433
|
+
uptimeMs == null ||
|
|
4434
|
+
uptimeMs > RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS) {
|
|
4435
|
+
return false;
|
|
4436
|
+
}
|
|
4437
|
+
const deaths = this.runtimeExitWindow.get(key) ?? [];
|
|
4438
|
+
deaths.push(exitedAt);
|
|
4439
|
+
// Roll the window around the most recent death. `exitedAt` is the client's
|
|
4440
|
+
// own recorded death time (#1127), which can arrive out of DETECTION order
|
|
4441
|
+
// — a death detected hours later still carries its true, older timestamp —
|
|
4442
|
+
// so anchor on the max, not on push order.
|
|
4443
|
+
const windowRef = Math.max(...deaths);
|
|
4444
|
+
const windowStart = windowRef - RUNTIME_EXIT_WINDOW_MS;
|
|
4445
|
+
let pruned = deaths.filter((t) => t >= windowStart);
|
|
4446
|
+
if (pruned.length > RUNTIME_EXIT_WINDOW_TRIP_COUNT) {
|
|
4447
|
+
pruned = pruned.slice(pruned.length - RUNTIME_EXIT_WINDOW_TRIP_COUNT);
|
|
4448
|
+
}
|
|
4449
|
+
this.runtimeExitWindow.set(key, pruned);
|
|
4450
|
+
return pruned.length >= RUNTIME_EXIT_WINDOW_TRIP_COUNT;
|
|
4451
|
+
}
|
|
3243
4452
|
/**
|
|
3244
4453
|
* Read-only circuit-breaker status, including server/root pairs that have no
|
|
3245
4454
|
* live client and would therefore be absent from getStatus().
|
|
4455
|
+
*
|
|
4456
|
+
* #1127: `failureCounts` (spawn/init failures) and `runtimeExitCounts`
|
|
4457
|
+
* (early post-init runtime exits) are two INDEPENDENT streams feeding one
|
|
4458
|
+
* circuit — either can trip its own cooldown/permanent-disable on its own
|
|
4459
|
+
* threshold, without the other. `failures` below is their SUM, purely for
|
|
4460
|
+
* display: it is the total churn behind whatever cooldown/permanent state
|
|
4461
|
+
* is showing, not a claim that both streams are at that count.
|
|
3246
4462
|
*/
|
|
3247
4463
|
getBrokenStatus() {
|
|
3248
4464
|
const keys = new Set([
|
|
@@ -3254,7 +4470,16 @@ export class LSPService {
|
|
|
3254
4470
|
return {
|
|
3255
4471
|
serverId: separator >= 0 ? key.slice(0, separator) : key,
|
|
3256
4472
|
root: separator >= 0 ? key.slice(separator + 1) : "",
|
|
3257
|
-
|
|
4473
|
+
// #1127/#1142: spawn/init failures, consecutive early post-init
|
|
4474
|
+
// exits, and the windowed-rate stream all feed the same breaker
|
|
4475
|
+
// (see `runtimeExitCounts`/`runtimeExitWindow` docs). Sum spawn/init
|
|
4476
|
+
// failures with the LARGER of the two runtime-exit streams — a
|
|
4477
|
+
// single <60s death feeds BOTH runtimeExitCounts and the window, so
|
|
4478
|
+
// `max` avoids double-counting them, while still surfacing a
|
|
4479
|
+
// windowed-only trip (whose runtimeExitCounts may be 0) instead of
|
|
4480
|
+
// misreporting `failures: 0` behind a permanent-disable.
|
|
4481
|
+
failures: (this.failureCounts.get(key) ?? 0) +
|
|
4482
|
+
Math.max(this.runtimeExitCounts.get(key) ?? 0, this.runtimeExitWindow.get(key)?.length ?? 0),
|
|
3258
4483
|
permanentlyBroken: this.permanentlyBroken.has(key),
|
|
3259
4484
|
cooldownUntil: this.state.broken.get(key) ?? 0,
|
|
3260
4485
|
};
|