@herbertgao/pi-extensions 2026.8.13 → 2026.8.15
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 +19 -14
- package/THIRD_PARTY_NOTICES.md +0 -1
- package/node_modules/@czottmann/pi-automode/CHANGELOG.md +19 -0
- package/node_modules/@czottmann/pi-automode/README.md +5 -1
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +126 -13
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +7 -3
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +2 -0
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +134 -23
- package/node_modules/@czottmann/pi-automode/package.json +1 -1
- package/node_modules/@herbertgao/{pi-stash → pi-bark}/LICENSE +4 -5
- package/node_modules/@herbertgao/pi-bark/README.md +44 -0
- package/node_modules/@herbertgao/pi-bark/assets/pi-icon.png +0 -0
- package/node_modules/@herbertgao/pi-bark/package.json +53 -0
- package/node_modules/@herbertgao/pi-bark/src/index.ts +178 -0
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +8 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/README.md +125 -88
- package/node_modules/@narumitw/pi-btw/dist/index.ts +140 -45
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +2 -2
- package/node_modules/@narumitw/pi-btw/package.json +5 -8
- package/node_modules/@narumitw/pi-btw/src/btw.ts +31 -26
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +106 -17
- package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
- package/node_modules/@narumitw/pi-btw/src/settings.ts +16 -0
- package/node_modules/@tifan/pi-handoff/README.md +36 -13
- package/node_modules/@tifan/pi-handoff/package.json +2 -2
- package/node_modules/@tifan/pi-handoff/src/index.ts +160 -4
- package/node_modules/@tifan/pi-preferred-thinking/README.md +1 -1
- package/node_modules/@tifan/pi-preferred-thinking/package.json +1 -1
- package/node_modules/@tifan/pi-preferred-thinking/src/index.ts +15 -2
- package/node_modules/@tifan/pi-recap/README.md +1 -1
- package/node_modules/@tifan/pi-recap/package.json +2 -2
- package/node_modules/@tifan/pi-recap/src/index.ts +15 -0
- package/node_modules/@tifan/pi-rename/README.md +42 -21
- package/node_modules/@tifan/pi-rename/package.json +1 -1
- package/node_modules/@tifan/pi-rename/src/index.ts +62 -11
- package/node_modules/@tifan/pi-rename/src/language.ts +18 -0
- package/node_modules/@tifan/pi-rename/src/models.ts +69 -13
- package/node_modules/@tifan/pi-rename/src/naming.ts +36 -7
- package/node_modules/@tifan/pi-rename/src/sanitize.ts +22 -14
- package/node_modules/pi-lens/CHANGELOG.md +320 -0
- package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +79 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +16 -1
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +13 -6
- package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +12 -0
- package/node_modules/pi-lens/dist/clients/biome-client.js +7 -0
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +20 -0
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +56 -19
- package/node_modules/pi-lens/dist/clients/build-identity.js +95 -0
- package/node_modules/pi-lens/dist/clients/bus-publish.js +3 -0
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +2 -1
- package/node_modules/pi-lens/dist/clients/cache-observability.js +2 -1
- package/node_modules/pi-lens/dist/clients/cascade-logger.js +15 -1
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +14 -11
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +56 -4
- package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +14 -0
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +20 -3
- package/node_modules/pi-lens/dist/clients/deps/minimatch.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +39 -0
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +150 -9
- package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +231 -9
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +205 -100
- package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +87 -0
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +21 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +174 -55
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +5 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +16 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +54 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +1 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +11 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +8 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +76 -25
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +7 -13
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +66 -1
- package/node_modules/pi-lens/dist/clients/file-utils.js +157 -7
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +5 -0
- package/node_modules/pi-lens/dist/clients/formatters.js +28 -3
- package/node_modules/pi-lens/dist/clients/git-guard.js +197 -47
- package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +32 -1
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +5 -1
- package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +7 -0
- package/node_modules/pi-lens/dist/clients/installer/index.js +131 -34
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +3 -1
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +4 -13
- package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +145 -0
- package/node_modules/pi-lens/dist/clients/instance-registry.js +432 -122
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +6 -1
- package/node_modules/pi-lens/dist/clients/language-profile.js +4 -2
- package/node_modules/pi-lens/dist/clients/latency-logger.js +19 -1
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
- package/node_modules/pi-lens/dist/clients/lens-map.js +3 -1
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +4 -0
- package/node_modules/pi-lens/dist/clients/lsp/client.js +516 -31
- package/node_modules/pi-lens/dist/clients/lsp/config.js +25 -2
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +9 -4
- package/node_modules/pi-lens/dist/clients/lsp/index.js +509 -182
- package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +5 -0
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +36 -8
- package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +116 -4
- package/node_modules/pi-lens/dist/clients/lsp/server.js +142 -13
- package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +99 -0
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +16 -6
- package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +32 -23
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +16 -2
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +12 -6
- package/node_modules/pi-lens/dist/clients/mcp/session.js +14 -1
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +23 -1
- package/node_modules/pi-lens/dist/clients/message-end-attribution.js +30 -0
- package/node_modules/pi-lens/dist/clients/metrics-history.js +21 -4
- package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +232 -18
- package/node_modules/pi-lens/dist/clients/package-manager.js +6 -18
- package/node_modules/pi-lens/dist/clients/path-keyed-map.js +14 -1
- package/node_modules/pi-lens/dist/clients/path-utils.js +32 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +0 -8
- package/node_modules/pi-lens/dist/clients/process-singletons.js +210 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +10 -4
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +33 -11
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +24 -2
- package/node_modules/pi-lens/dist/clients/read-guard.js +74 -7
- package/node_modules/pi-lens/dist/clients/recent-touches.js +17 -5
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +639 -62
- package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +5 -1
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +21 -1
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +68 -0
- package/node_modules/pi-lens/dist/clients/runtime-session.js +30 -12
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +15 -0
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +106 -7
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +242 -27
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +158 -18
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +48 -17
- package/node_modules/pi-lens/dist/clients/session-lifecycle.js +238 -42
- package/node_modules/pi-lens/dist/clients/sg-runner.js +27 -15
- package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +338 -0
- package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +37 -0
- package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +15 -1
- package/node_modules/pi-lens/dist/clients/startup-scan.js +6 -7
- package/node_modules/pi-lens/dist/clients/startup-timing.js +69 -4
- package/node_modules/pi-lens/dist/clients/string-utils.js +20 -0
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +419 -88
- package/node_modules/pi-lens/dist/clients/tool-policy.js +1 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +11 -5
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +17 -1
- package/node_modules/pi-lens/dist/clients/warm-attach.js +8 -10
- package/node_modules/pi-lens/dist/clients/word-index-logger.js +13 -1
- package/node_modules/pi-lens/dist/clients/word-index-store.js +448 -0
- package/node_modules/pi-lens/dist/clients/word-index.js +582 -104
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +17 -0
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -6
- package/node_modules/pi-lens/dist/index.js +11243 -6651
- package/node_modules/pi-lens/dist/mcp/build-staleness.js +8 -7
- package/node_modules/pi-lens/dist/mcp/cli.js +3 -1
- package/node_modules/pi-lens/dist/mcp/server.js +6 -1
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +4 -0
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +14 -5
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +3 -1
- package/node_modules/pi-lens/docs/agent-guide.md +4 -2
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +41 -10
- package/node_modules/pi-lens/docs/astplayground.md +26 -17
- package/node_modules/pi-lens/package.json +5 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-important.yml +21 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +35 -0
- package/node_modules/pi-mcp-adapter/README.md +5 -2
- package/node_modules/pi-mcp-adapter/cli.js +4 -4
- package/node_modules/pi-mcp-adapter/commands.ts +1 -1
- package/node_modules/pi-mcp-adapter/config.ts +1 -0
- package/node_modules/pi-mcp-adapter/dist/config.js +1 -0
- package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.d.ts +24 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js +336 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js.map +1 -0
- package/node_modules/pi-mcp-adapter/dist/types.d.ts +3 -1
- package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
- package/node_modules/pi-mcp-adapter/index.ts +90 -6
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +124 -32
- package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +0 -2
- package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +89 -0
- package/node_modules/pi-mcp-adapter/mcp-references.ts +9 -1
- package/node_modules/pi-mcp-adapter/package.json +1 -1
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +62 -22
- package/node_modules/pi-mcp-adapter/request-headers-command.ts +1 -1
- package/node_modules/pi-mcp-adapter/server-manager.ts +6 -0
- package/node_modules/pi-mcp-adapter/types.ts +3 -1
- package/node_modules/pi-web-access/CHANGELOG.md +42 -0
- package/node_modules/pi-web-access/README.md +26 -10
- package/node_modules/pi-web-access/curator-page.ts +12 -3
- package/node_modules/pi-web-access/curator-server.ts +3 -1
- package/node_modules/pi-web-access/extract.ts +88 -13
- package/node_modules/pi-web-access/gemini-search.ts +10 -5
- package/node_modules/pi-web-access/github-extract.ts +47 -3
- package/node_modules/pi-web-access/index.ts +40 -40
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/page-query.ts +61 -8
- package/node_modules/pi-web-access/xcrawl.ts +264 -0
- package/package.json +19 -22
- package/node_modules/@herbertgao/pi-stash/README.md +0 -34
- package/node_modules/@herbertgao/pi-stash/package.json +0 -51
- package/node_modules/@herbertgao/pi-stash/src/index.ts +0 -118
- package/node_modules/@tifan/pi-titlebar-spinner/README.md +0 -24
- package/node_modules/@tifan/pi-titlebar-spinner/package.json +0 -39
- package/node_modules/@tifan/pi-titlebar-spinner/src/index.ts +0 -49
|
@@ -17,7 +17,7 @@ import { recordLsp } from "../widget-state.js";
|
|
|
17
17
|
import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
|
|
18
18
|
import { detectFileRole } from "../file-role.js";
|
|
19
19
|
import { emitBounded } from "../bounded-telemetry.js";
|
|
20
|
-
import { logLatency } from "../latency-logger.js";
|
|
20
|
+
import { logLatency, phaseFinished, phaseStarted } from "../latency-logger.js";
|
|
21
21
|
import { logSessionStart } from "../sessionstart-logger.js";
|
|
22
22
|
import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
|
|
23
23
|
import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
|
|
@@ -25,12 +25,17 @@ import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
|
|
|
25
25
|
import { withDeadline, withTimeout } from "../deadline-utils.js";
|
|
26
26
|
import { acquireWorkspaceSweepHold, clearWorkspaceSweepHoldForSessionStart, } from "./workspace-sweep-hold.js";
|
|
27
27
|
import { DocumentDriftTracker, fingerprintDocumentContent, } from "./document-drift.js";
|
|
28
|
-
import { markPendingAuxiliaryCoverage } from "./pending-aux-coverage.js";
|
|
28
|
+
import { markPendingAuxiliaryCoverage, napiFallbackCoveredSince, } from "./pending-aux-coverage.js";
|
|
29
29
|
import { isAtOrAboveHomeDir, isWindowsPath, normalizeMapKey, uriToPath, } from "../path-utils.js";
|
|
30
30
|
import { recordLspMutation } from "../lsp-mutation.js";
|
|
31
31
|
import { createLSPClient } from "./client.js";
|
|
32
32
|
import { auxiliaryCoverageGap, bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, hashDiagnosticContent, resolveTouchVerdict, touchCoverageGap, } from "./diagnostic-binding.js";
|
|
33
33
|
import { getServersForFileWithConfig, getServerInitOverride, } from "./config.js";
|
|
34
|
+
// #2052: deliberately NOT taken from `config.js`. This module's import surface
|
|
35
|
+
// from `config.js` is mirrored by explicit `vi.mock` factories in ~58 test
|
|
36
|
+
// files, so every new symbol taken from there breaks all of them.
|
|
37
|
+
import { getSessionRootsForTelemetry, isOutsideAllSessionRoots, } from "./session-roots.js";
|
|
38
|
+
import { getProcessSingleton } from "../process-singletons.js";
|
|
34
39
|
import { getLanguageId } from "./language.js";
|
|
35
40
|
import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, } from "./server.js";
|
|
36
41
|
import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
|
|
@@ -301,6 +306,19 @@ function readEnvAuxGraceMs() {
|
|
|
301
306
|
return undefined;
|
|
302
307
|
return parsed;
|
|
303
308
|
}
|
|
309
|
+
const DEFAULT_AUX_GRACE_CEILING_MS = 2000;
|
|
310
|
+
const MAX_ADAPTIVE_AUX_GRACE_CEILING_MS = 8000;
|
|
311
|
+
const ADAPTIVE_AUX_GRACE_MARGIN_MS = 500;
|
|
312
|
+
export function auxWaitBudgetMs(serverId, isCold, configuredCeilingMs, declaredWaitMs) {
|
|
313
|
+
if (configuredCeilingMs !== undefined || !isCold) {
|
|
314
|
+
return Math.min(declaredWaitMs, configuredCeilingMs ?? DEFAULT_AUX_GRACE_CEILING_MS);
|
|
315
|
+
}
|
|
316
|
+
const observedSpawnMs = getSuccessfulLspSpawnDurationMs(serverId);
|
|
317
|
+
if (observedSpawnMs === undefined || observedSpawnMs <= 0) {
|
|
318
|
+
return Math.min(declaredWaitMs, DEFAULT_AUX_GRACE_CEILING_MS);
|
|
319
|
+
}
|
|
320
|
+
return Math.min(MAX_ADAPTIVE_AUX_GRACE_CEILING_MS, Math.max(declaredWaitMs, observedSpawnMs + ADAPTIVE_AUX_GRACE_MARGIN_MS));
|
|
321
|
+
}
|
|
304
322
|
const DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_THRESHOLD_MS ?? "250", 10) || 250);
|
|
305
323
|
const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_SETTLE_MS ?? "400", 10) || 400);
|
|
306
324
|
// Once the fastest client has diagnostics, remaining clients get this window before
|
|
@@ -576,10 +594,12 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
|
|
|
576
594
|
}
|
|
577
595
|
// --- Service ---
|
|
578
596
|
export class LSPService {
|
|
597
|
+
sessionCwd;
|
|
579
598
|
state;
|
|
580
599
|
workspaceProbeLogged = new Set();
|
|
581
600
|
/** Per-service immutable root-boundary verdicts; root detectors cache hits too. */
|
|
582
601
|
projectBoundaryCache = new Map();
|
|
602
|
+
/** Foreign roots are recorded once per normalized root for this service/session. */
|
|
583
603
|
warmStartLogged = new Set();
|
|
584
604
|
optionalFailureLogged = new Set();
|
|
585
605
|
/** Server/root pairs that already emitted unavailable for the current occurrence. */
|
|
@@ -755,8 +775,9 @@ export class LSPService {
|
|
|
755
775
|
* a permanent promise/microtask tax.
|
|
756
776
|
*/
|
|
757
777
|
generationHandoff;
|
|
758
|
-
constructor(generationHandoff) {
|
|
778
|
+
constructor(generationHandoff, sessionCwd) {
|
|
759
779
|
this.generationHandoff = generationHandoff;
|
|
780
|
+
this.sessionCwd = sessionCwd;
|
|
760
781
|
this.state = {
|
|
761
782
|
clients: new Map(),
|
|
762
783
|
servers: new Map(),
|
|
@@ -776,6 +797,14 @@ export class LSPService {
|
|
|
776
797
|
const candidate = await server.root(filePath);
|
|
777
798
|
if (!candidate)
|
|
778
799
|
return undefined;
|
|
800
|
+
// #2052: a file outside EVERY initialized session cwd gets no client at
|
|
801
|
+
// all. The ceiling below is unchanged (`process.cwd()`, as before this
|
|
802
|
+
// issue) and still only clamps files that are actually inside it —
|
|
803
|
+
// `enforceLspRootCeiling` returns the root untouched for an out-of-ceiling
|
|
804
|
+
// file. So this gate is the ONLY new refusal, and it consults the
|
|
805
|
+
// registry, not a single cwd.
|
|
806
|
+
if (isOutsideAllSessionRoots(filePath))
|
|
807
|
+
return undefined;
|
|
779
808
|
const root = enforceLspRootCeiling(candidate, process.cwd(), filePath);
|
|
780
809
|
if (normalizeMapKey(root) === normalizeMapKey(process.cwd()))
|
|
781
810
|
return root;
|
|
@@ -1621,7 +1650,11 @@ export class LSPService {
|
|
|
1621
1650
|
// #1934: the first server whose acquisition ERRORED, as opposed to
|
|
1622
1651
|
// cleanly declining. Kept so a selection that served nobody still
|
|
1623
1652
|
// says which server the pool actually tried and failed to spawn.
|
|
1653
|
+
// #2064 carries the errored outcome VALUE with it, so the record
|
|
1654
|
+
// below reports whether this caller started the failed spawn or
|
|
1655
|
+
// joined it, instead of pinning a starter label on every joiner.
|
|
1624
1656
|
let erroredServerId;
|
|
1657
|
+
let erroredOutcome;
|
|
1625
1658
|
// Try each matching server
|
|
1626
1659
|
for (const server of servers) {
|
|
1627
1660
|
// A box, not a `let`: control-flow analysis cannot see the callback
|
|
@@ -1650,8 +1683,12 @@ export class LSPService {
|
|
|
1650
1683
|
});
|
|
1651
1684
|
return spawned;
|
|
1652
1685
|
}
|
|
1653
|
-
if (acquisition.outcome === "spawn-failure"
|
|
1654
|
-
|
|
1686
|
+
if (acquisition.outcome === "spawn-failure" ||
|
|
1687
|
+
acquisition.outcome === "spawn-failure-joined") {
|
|
1688
|
+
if (erroredServerId === undefined) {
|
|
1689
|
+
erroredServerId = server.id;
|
|
1690
|
+
erroredOutcome = acquisition.outcome;
|
|
1691
|
+
}
|
|
1655
1692
|
}
|
|
1656
1693
|
}
|
|
1657
1694
|
if (erroredServerId !== undefined) {
|
|
@@ -1668,7 +1705,8 @@ export class LSPService {
|
|
|
1668
1705
|
metadata: {
|
|
1669
1706
|
serverId: erroredServerId,
|
|
1670
1707
|
candidateCount: servers.length,
|
|
1671
|
-
outcome:
|
|
1708
|
+
outcome: (erroredOutcome ??
|
|
1709
|
+
"spawn-failure"),
|
|
1672
1710
|
},
|
|
1673
1711
|
});
|
|
1674
1712
|
}
|
|
@@ -1802,13 +1840,13 @@ export class LSPService {
|
|
|
1802
1840
|
* the enabled set. These attach alongside the primary on the with-auxiliary
|
|
1803
1841
|
* diagnostics path (cross-cutting scanners like opengrep).
|
|
1804
1842
|
*/
|
|
1805
|
-
async getAuxiliaryClientsForFile(filePath, enabledIds) {
|
|
1843
|
+
async getAuxiliaryClientsForFile(filePath, enabledIds, onOutcome) {
|
|
1806
1844
|
if (this.checkDestroyed() || enabledIds.size === 0)
|
|
1807
1845
|
return [];
|
|
1808
1846
|
const servers = getServersForFileWithConfig(filePath).filter((s) => s.role === "auxiliary" && enabledIds.has(s.id));
|
|
1809
1847
|
if (servers.length === 0)
|
|
1810
1848
|
return [];
|
|
1811
|
-
const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
|
|
1849
|
+
const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server, undefined, undefined, (reported) => onOutcome?.(server.id, reported))));
|
|
1812
1850
|
return spawned.filter((entry) => Boolean(entry));
|
|
1813
1851
|
}
|
|
1814
1852
|
/**
|
|
@@ -1857,11 +1895,11 @@ export class LSPService {
|
|
|
1857
1895
|
return undefined;
|
|
1858
1896
|
}
|
|
1859
1897
|
/**
|
|
1860
|
-
* Read-only
|
|
1861
|
-
*
|
|
1862
|
-
*
|
|
1898
|
+
* Read-only Gate-B readiness check for one auxiliary server/root. A connected
|
|
1899
|
+
* process is not yet a diagnostic producer: only its first publication proves
|
|
1900
|
+
* that this root's client can supersede an in-process fallback runner.
|
|
1863
1901
|
*/
|
|
1864
|
-
async
|
|
1902
|
+
async hasServerPublishedForFileRoot(serverId, filePath) {
|
|
1865
1903
|
if (this.checkDestroyed())
|
|
1866
1904
|
return false;
|
|
1867
1905
|
for (const server of getServersForFileWithConfig(filePath)) {
|
|
@@ -1871,20 +1909,28 @@ export class LSPService {
|
|
|
1871
1909
|
if (!root)
|
|
1872
1910
|
continue;
|
|
1873
1911
|
const key = `${server.id}:${normalizeMapKey(root)}`;
|
|
1874
|
-
|
|
1912
|
+
const client = this.state.clients.get(key);
|
|
1913
|
+
// #2324 F1: per-file grain. `diagnosticsVersion` is client-global —
|
|
1914
|
+
// any sibling path's publication bumps it, so it cannot answer "did
|
|
1915
|
+
// THIS server publish for THIS file?" (client.ts:339-341). A file
|
|
1916
|
+
// this server never touched would otherwise read as gated-open the
|
|
1917
|
+
// moment any other file on the same client got a publication.
|
|
1918
|
+
if (client?.isAlive() &&
|
|
1919
|
+
client.getDiagnosticsVersionForPath(filePath) > 0) {
|
|
1875
1920
|
return true;
|
|
1921
|
+
}
|
|
1876
1922
|
}
|
|
1877
1923
|
return false;
|
|
1878
1924
|
}
|
|
1879
1925
|
/**
|
|
1880
1926
|
* #2001/#2002 collect-later: read-only cached-diagnostics probe for the
|
|
1881
1927
|
* turn-end late-auxiliary delivery (`clients/runtime-turn.ts`). Like
|
|
1882
|
-
*
|
|
1928
|
+
* the Gate-B readiness check, this NEVER creates or warms a client — it only
|
|
1883
1929
|
* resolves each requested server's root and reads the already-connected
|
|
1884
1930
|
* client's cached diagnostics for `filePath`. Servers with no live client
|
|
1885
|
-
* are simply absent from the returned map
|
|
1886
|
-
*
|
|
1887
|
-
*
|
|
1931
|
+
* are simply absent from the returned map. A live client is present only
|
|
1932
|
+
* when its per-file cache entry exists; its timestamp distinguishes a
|
|
1933
|
+
* published clean result from no publication.
|
|
1888
1934
|
*/
|
|
1889
1935
|
async readCachedDiagnosticsForServers(filePath, serverIds) {
|
|
1890
1936
|
const out = new Map();
|
|
@@ -1900,7 +1946,11 @@ export class LSPService {
|
|
|
1900
1946
|
const client = this.state.clients.get(key);
|
|
1901
1947
|
if (!client?.isAlive())
|
|
1902
1948
|
continue;
|
|
1903
|
-
|
|
1949
|
+
const entry = client.getAllDiagnostics().get(normalizeMapKey(filePath));
|
|
1950
|
+
out.set(server.id, {
|
|
1951
|
+
diags: entry?.diags ?? [],
|
|
1952
|
+
publishedAt: entry?.ts,
|
|
1953
|
+
});
|
|
1904
1954
|
}
|
|
1905
1955
|
return out;
|
|
1906
1956
|
}
|
|
@@ -2372,12 +2422,21 @@ export class LSPService {
|
|
|
2372
2422
|
if (isOptionalServer)
|
|
2373
2423
|
this.optionalDisabled.delete(key);
|
|
2374
2424
|
}
|
|
2425
|
+
// #2064: did THIS caller start the language-server process, or did it
|
|
2426
|
+
// join a spawn another caller already had in flight? The answer is only
|
|
2427
|
+
// knowable here, before the await — downstream of `await spawnPromise`
|
|
2428
|
+
// the two are indistinguishable, which is exactly how the 3.0x
|
|
2429
|
+
// over-count happened. There are two join sites and both count as
|
|
2430
|
+
// joins: the unguarded read below, and the `raced` re-read inside the
|
|
2431
|
+
// spawn gate, which catches a caller that reached the gate before the
|
|
2432
|
+
// starter published its promise.
|
|
2433
|
+
let startedSpawn = false;
|
|
2375
2434
|
let spawnPromise = this.state.inFlight.get(key);
|
|
2376
2435
|
if (!spawnPromise) {
|
|
2377
2436
|
const started = await this.withClientSpawnGate(async () => {
|
|
2378
2437
|
const raced = this.state.inFlight.get(key);
|
|
2379
2438
|
if (raced)
|
|
2380
|
-
return { promise: raced };
|
|
2439
|
+
return { promise: raced, startedSpawn: false };
|
|
2381
2440
|
// `server.root()` and dead-client cleanup above are async. A reset during
|
|
2382
2441
|
// either gap must not let this retired generation start a late spawn.
|
|
2383
2442
|
if (this.checkDestroyed())
|
|
@@ -2386,11 +2445,12 @@ export class LSPService {
|
|
|
2386
2445
|
return undefined;
|
|
2387
2446
|
const promise = this.spawnClient(server, root, key, filePath, allowInstall);
|
|
2388
2447
|
this.state.inFlight.set(key, promise);
|
|
2389
|
-
return { promise };
|
|
2448
|
+
return { promise, startedSpawn: true };
|
|
2390
2449
|
});
|
|
2391
2450
|
if (!started)
|
|
2392
2451
|
return undefined;
|
|
2393
2452
|
spawnPromise = started.promise;
|
|
2453
|
+
startedSpawn = started.startedSpawn;
|
|
2394
2454
|
}
|
|
2395
2455
|
// Announce the in-flight spawn so the caller can skip a doomed touch
|
|
2396
2456
|
// wait. The announcement never returns a client and never
|
|
@@ -2410,18 +2470,41 @@ export class LSPService {
|
|
|
2410
2470
|
// #1934: a client here cost a process WAIT, whether this caller
|
|
2411
2471
|
// started the spawn or joined another caller's in-flight promise.
|
|
2412
2472
|
// Either way the selection was not served from the warm pool.
|
|
2473
|
+
// #2064: which of the two it was is now named, so the record can
|
|
2474
|
+
// answer "how many processes started" as well as "how many
|
|
2475
|
+
// selections paid a wait". `startedSpawn` is captured before the
|
|
2476
|
+
// await, because after it the two are indistinguishable.
|
|
2413
2477
|
//
|
|
2414
2478
|
// The verdict read is synchronous and sits in the same microtask as
|
|
2415
2479
|
// the await above, so it can only see the attempt just settled.
|
|
2416
2480
|
onOutcome?.(spawned
|
|
2417
|
-
?
|
|
2481
|
+
? startedSpawn
|
|
2482
|
+
? "cold-spawn"
|
|
2483
|
+
: "cold-spawn-joined"
|
|
2418
2484
|
: this.lastSpawnVerdict.get(key) === "failed"
|
|
2419
|
-
?
|
|
2485
|
+
? startedSpawn
|
|
2486
|
+
? "spawn-failure"
|
|
2487
|
+
: "spawn-failure-joined"
|
|
2420
2488
|
: "declined");
|
|
2421
2489
|
return spawned;
|
|
2422
2490
|
}
|
|
2423
2491
|
catch (err) {
|
|
2424
2492
|
// A throwing spawn promise is an errored acquisition by definition.
|
|
2493
|
+
//
|
|
2494
|
+
// #2064 review F2: deliberately NOT split into a starter/joiner pair
|
|
2495
|
+
// like the resolve path above, because nothing could observe the
|
|
2496
|
+
// split. `spawnClient` never rethrows; it catches its own spawn and
|
|
2497
|
+
// initialize failures and resolves `undefined`. This catch is
|
|
2498
|
+
// therefore reachable only when `spawnClient` throws BEFORE its own
|
|
2499
|
+
// `try` (the trust probe, `logSessionStart`, `recordLsp`). A joiner
|
|
2500
|
+
// CAN read the already-rejected promise out of `inFlight` (the #2106
|
|
2501
|
+
// verify measured 2 invocations for 3 callers), but it does not
|
|
2502
|
+
// matter: the rethrow
|
|
2503
|
+
// below unwinds past every `lsp_client_selected` emit site, so this
|
|
2504
|
+
// value is written to no record: a probe drove a throwing trust
|
|
2505
|
+
// check through two concurrent callers and got two rejections and
|
|
2506
|
+
// zero selection records. An unobservable discriminator is a
|
|
2507
|
+
// vacuous guard, so this path keeps the single #1934 value.
|
|
2425
2508
|
onOutcome?.("spawn-failure");
|
|
2426
2509
|
throw err;
|
|
2427
2510
|
}
|
|
@@ -2514,6 +2597,7 @@ export class LSPService {
|
|
|
2514
2597
|
serverId: server.id,
|
|
2515
2598
|
process: spawned.process,
|
|
2516
2599
|
root,
|
|
2600
|
+
sessionCwd: this.sessionCwd,
|
|
2517
2601
|
initialization: mergedInit,
|
|
2518
2602
|
initializeTimeoutMs: server.initializeTimeoutMs,
|
|
2519
2603
|
launchVariant: spawned.launchVariant,
|
|
@@ -2550,6 +2634,36 @@ export class LSPService {
|
|
|
2550
2634
|
const spawnDurationMs = Date.now() - startedAt;
|
|
2551
2635
|
recordLsp(server.id, root, "spawn_success", spawnDurationMs);
|
|
2552
2636
|
recordSuccessfulLspSpawn(server.id, spawnDurationMs);
|
|
2637
|
+
// #2064: the only latency record that a language-server PROCESS
|
|
2638
|
+
// started. `lsp_launch_candidate_success` covers the servers that
|
|
2639
|
+
// launch through `resolveAndLaunch` and never fired for TypeScript,
|
|
2640
|
+
// which served 913 of 941 selections in the field window — so
|
|
2641
|
+
// nothing in `latency.log` counted the 29.3s TypeScript spawn at
|
|
2642
|
+
// all. This sits at `spawnClient`'s single success path, so every
|
|
2643
|
+
// server reports through one record instead of a per-server
|
|
2644
|
+
// launcher, and `count(serverId=typescript)` is answerable from
|
|
2645
|
+
// `latency.log` alone. Volume is bounded by process starts: one
|
|
2646
|
+
// record per spawn, and a spawn is single-flighted per
|
|
2647
|
+
// `serverId:root` by `state.inFlight`.
|
|
2648
|
+
//
|
|
2649
|
+
// The two path fields are deliberate and are not duplicates.
|
|
2650
|
+
// `filePath` carries the ROOT, because the root plus `serverId` is
|
|
2651
|
+
// the client's identity and the unit a spawn is single-flighted on.
|
|
2652
|
+
// `triggerFilePath` carries the file whose touch paid for the
|
|
2653
|
+
// spawn, which answers a different question and is the one that
|
|
2654
|
+
// varies across records for the same client.
|
|
2655
|
+
logLatency({
|
|
2656
|
+
type: "phase",
|
|
2657
|
+
phase: "lsp_server_spawned",
|
|
2658
|
+
filePath: root,
|
|
2659
|
+
durationMs: spawnDurationMs,
|
|
2660
|
+
metadata: {
|
|
2661
|
+
serverId: server.id,
|
|
2662
|
+
source: spawned.source ?? "unknown",
|
|
2663
|
+
launchVariant: spawned.launchVariant ?? "default",
|
|
2664
|
+
triggerFilePath: filePath,
|
|
2665
|
+
},
|
|
2666
|
+
});
|
|
2553
2667
|
if (!this.workspaceProbeLogged.has(key)) {
|
|
2554
2668
|
logSessionStart(`lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`);
|
|
2555
2669
|
this.workspaceProbeLogged.add(key);
|
|
@@ -2646,6 +2760,11 @@ export class LSPService {
|
|
|
2646
2760
|
}
|
|
2647
2761
|
const startedAt = Date.now();
|
|
2648
2762
|
const normalizedPath = normalizeMapKey(filePath);
|
|
2763
|
+
const outsideRoot = await this.findOutsideProjectRoot(filePath);
|
|
2764
|
+
if (outsideRoot) {
|
|
2765
|
+
this.recordOutsideRootDecline(filePath, outsideRoot, "touchFile");
|
|
2766
|
+
return { diags: [], skipReason: "outside-project-root" };
|
|
2767
|
+
}
|
|
2649
2768
|
// #1783: every path that asks a language server anything comes through
|
|
2650
2769
|
// here, so this is where the disk-drift backstop gets its heartbeat.
|
|
2651
2770
|
// Deliberately NOT awaited: the sweep is rate-limited to one pass per
|
|
@@ -2665,6 +2784,12 @@ export class LSPService {
|
|
|
2665
2784
|
const useAllClients = clientScope === "all";
|
|
2666
2785
|
const resolvedPrimaryRoots = new Map();
|
|
2667
2786
|
const waitSkipReasons = new Set();
|
|
2787
|
+
const coldAuxiliaryServerIds = new Set();
|
|
2788
|
+
const noteColdAuxiliary = (serverId, outcome) => {
|
|
2789
|
+
if (outcome === "cold-spawn" || outcome === "cold-spawn-joined") {
|
|
2790
|
+
coldAuxiliaryServerIds.add(serverId);
|
|
2791
|
+
}
|
|
2792
|
+
};
|
|
2668
2793
|
let spawned;
|
|
2669
2794
|
let serverCountAttempted;
|
|
2670
2795
|
if (useAllClients) {
|
|
@@ -2677,7 +2802,7 @@ export class LSPService {
|
|
|
2677
2802
|
// (opengrep, …). The aggregation layer merges/dedups their diagnostics.
|
|
2678
2803
|
const [entry, aux] = await Promise.all([
|
|
2679
2804
|
this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons),
|
|
2680
|
-
this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? [])),
|
|
2805
|
+
this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? []), noteColdAuxiliary),
|
|
2681
2806
|
]);
|
|
2682
2807
|
spawned = entry ? [entry, ...aux] : aux;
|
|
2683
2808
|
serverCountAttempted = spawned.length;
|
|
@@ -2780,6 +2905,10 @@ export class LSPService {
|
|
|
2780
2905
|
// quietly reverting to the global counter, which is the defect itself.
|
|
2781
2906
|
const readPathVersion = (client) => client.getDiagnosticsVersionForPath?.(filePath);
|
|
2782
2907
|
const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, readPathVersion(entry.client)]));
|
|
2908
|
+
// #2161: anchor publication evidence before this touch's notify. A
|
|
2909
|
+
// later per-file cache entry, including an empty one, proves that the
|
|
2910
|
+
// primary answered; an empty diagnostics result alone cannot.
|
|
2911
|
+
const markedAtMs = Date.now();
|
|
2783
2912
|
// #1458: read a late auxiliary publication BEFORE the ordinary resync
|
|
2784
2913
|
// clears its client cache. Carry it only when the publication's exact
|
|
2785
2914
|
// sent-content fingerprint matches this touch's content. A changed edit,
|
|
@@ -3327,7 +3456,25 @@ export class LSPService {
|
|
|
3327
3456
|
// Fail-open: missing capability state keeps today's push fallback.
|
|
3328
3457
|
}
|
|
3329
3458
|
}
|
|
3330
|
-
const
|
|
3459
|
+
const configuredAuxCeilingMs = readEnvAuxGraceMs();
|
|
3460
|
+
const perServerDeclaredTimeouts = spawned.map((entry) => timeoutFor(entry.client.serverId));
|
|
3461
|
+
const perServerWaitTimeouts = spawned.map((entry, entryIndex) => {
|
|
3462
|
+
const declaredServerTimeout = perServerDeclaredTimeouts[entryIndex];
|
|
3463
|
+
const observedSpawnMs = getSuccessfulLspSpawnDurationMs(entry.client.serverId);
|
|
3464
|
+
return hasTouchAuxiliaries &&
|
|
3465
|
+
entry.info.role === "auxiliary" &&
|
|
3466
|
+
coldAuxiliaryServerIds.has(entry.client.serverId) &&
|
|
3467
|
+
(configuredAuxCeilingMs !== undefined ||
|
|
3468
|
+
(observedSpawnMs !== undefined && observedSpawnMs > 0))
|
|
3469
|
+
? auxWaitBudgetMs(entry.client.serverId, true, configuredAuxCeilingMs, declaredServerTimeout)
|
|
3470
|
+
: perServerDeclaredTimeouts[entryIndex];
|
|
3471
|
+
});
|
|
3472
|
+
const perServerRaceBudgets = spawned.map((entry, entryIndex) => {
|
|
3473
|
+
return hasTouchAuxiliaries && entry.info.role === "auxiliary"
|
|
3474
|
+
? auxWaitBudgetMs(entry.client.serverId, coldAuxiliaryServerIds.has(entry.client.serverId), configuredAuxCeilingMs, perServerDeclaredTimeouts[entryIndex])
|
|
3475
|
+
: perServerDeclaredTimeouts[entryIndex];
|
|
3476
|
+
});
|
|
3477
|
+
const perServerWaits = spawned.map((entry, entryIndex) => {
|
|
3331
3478
|
// #1459: a DEFERRED server never received this content, so its version
|
|
3332
3479
|
// can never advance past the baseline — waiting on it burns its whole
|
|
3333
3480
|
// budget and would flip the touch to `inconclusive`, discarding a
|
|
@@ -3336,7 +3483,7 @@ export class LSPService {
|
|
|
3336
3483
|
if (deferredResyncServerIds.has(entry.info.id)) {
|
|
3337
3484
|
return Promise.resolve(undefined);
|
|
3338
3485
|
}
|
|
3339
|
-
const serverTimeout =
|
|
3486
|
+
const serverTimeout = perServerWaitTimeouts[entryIndex];
|
|
3340
3487
|
// #1531: a per-path baseline. `clientWaitForDiagnostics` compares it
|
|
3341
3488
|
// against this path's own publication stamp, so a sibling file's
|
|
3342
3489
|
// publication on a shared client can no longer end this wait before the
|
|
@@ -3395,14 +3542,13 @@ export class LSPService {
|
|
|
3395
3542
|
serverId: spawned[i].info.id,
|
|
3396
3543
|
client: spawned[i].client,
|
|
3397
3544
|
baseline: diagnosticBaselines.get(spawned[i].client),
|
|
3545
|
+
budgetMs: perServerRaceBudgets[i],
|
|
3398
3546
|
}
|
|
3399
3547
|
: null)
|
|
3400
3548
|
.filter((x) => x !== null);
|
|
3401
|
-
|
|
3402
|
-
//
|
|
3403
|
-
//
|
|
3404
|
-
// 2000ms default admits measured ~1.3s warm scanner runs without
|
|
3405
|
-
// making every edit pay opengrep's 3500ms cold-start allowance.
|
|
3549
|
+
// After all primaries settle, use the same per-auxiliary budget
|
|
3550
|
+
// that bounded its own diagnostic wait. Warm acquisitions retain
|
|
3551
|
+
// the 2000ms ceiling; cold acquisitions include observed startup.
|
|
3406
3552
|
// Late aux results are dropped from this wait. A later unchanged-
|
|
3407
3553
|
// content read may carry a SHA-256-bound cache publication before its
|
|
3408
3554
|
// resync clears the cache; changed or unknown content never replays.
|
|
@@ -3415,7 +3561,7 @@ export class LSPService {
|
|
|
3415
3561
|
return;
|
|
3416
3562
|
const auxWaitStartedAt = Date.now();
|
|
3417
3563
|
const outcomes = await Promise.all(auxWaits.map(async (aux) => {
|
|
3418
|
-
const budgetMs =
|
|
3564
|
+
const { budgetMs } = aux;
|
|
3419
3565
|
let timer;
|
|
3420
3566
|
const timeout = new Promise((resolve) => {
|
|
3421
3567
|
timer = setTimeout(() => resolve(false), budgetMs);
|
|
@@ -3515,7 +3661,24 @@ export class LSPService {
|
|
|
3515
3661
|
// revision and probing it would replay stale findings.
|
|
3516
3662
|
const collectLaterServerIds = outcomes
|
|
3517
3663
|
.filter((o) => !o.publishedThisContent &&
|
|
3518
|
-
(o.outcome === "cut_off" || o.outcome === "silent")
|
|
3664
|
+
(o.outcome === "cut_off" || o.outcome === "silent") &&
|
|
3665
|
+
// #2324 R2-A/R3-A: ast-grep's napi fallback is a
|
|
3666
|
+
// SECOND producer of coverage for this exact pair,
|
|
3667
|
+
// dispatched CONCURRENTLY with this whole touch
|
|
3668
|
+
// (dispatcher.ts's Promise.all groups) — not just
|
|
3669
|
+
// concurrently with this wait. On a COLD touch,
|
|
3670
|
+
// getClientsForFile's spawn+handshake (above, before
|
|
3671
|
+
// waitStartedAt is ever captured) can itself take
|
|
3672
|
+
// long enough that napi's near-instant Gate-B check
|
|
3673
|
+
// records coverage BEFORE waitStartedAt — the
|
|
3674
|
+
// issue's own 68ms race shape. Baseline on startedAt
|
|
3675
|
+
// instead: stamped at touchFile's own entry, before
|
|
3676
|
+
// ANY spawn work, so it is the earliest instant this
|
|
3677
|
+
// touch's own napi run could possibly predate. Still
|
|
3678
|
+
// excludes a stale record from an EARLIER touch,
|
|
3679
|
+
// which is all the staleness guard needs.
|
|
3680
|
+
!(o.serverId === "ast-grep" &&
|
|
3681
|
+
napiFallbackCoveredSince(filePath, startedAt)))
|
|
3519
3682
|
.map((o) => o.serverId);
|
|
3520
3683
|
if (collectLaterServerIds.length > 0) {
|
|
3521
3684
|
markPendingAuxiliaryCoverage(filePath, collectLaterServerIds);
|
|
@@ -3576,8 +3739,11 @@ export class LSPService {
|
|
|
3576
3739
|
// Push already answered (settled, or published diagnostics that
|
|
3577
3740
|
// its wait is about to settle on) — nothing to confirm, no sync
|
|
3578
3741
|
// request goes out.
|
|
3742
|
+
const publishedAt = primaryClient
|
|
3743
|
+
.getAllDiagnostics?.()
|
|
3744
|
+
.get(normalizedPath)?.ts;
|
|
3579
3745
|
if (pushWaitSettled ||
|
|
3580
|
-
|
|
3746
|
+
(publishedAt !== undefined && publishedAt > markedAtMs)) {
|
|
3581
3747
|
return new Promise(() => { });
|
|
3582
3748
|
}
|
|
3583
3749
|
try {
|
|
@@ -4492,6 +4658,49 @@ export class LSPService {
|
|
|
4492
4658
|
this.releaseClientLease(key);
|
|
4493
4659
|
}
|
|
4494
4660
|
}
|
|
4661
|
+
/**
|
|
4662
|
+
* #2052: the file's nearest marker root, but ONLY when the file is outside
|
|
4663
|
+
* every initialized session cwd. Returns undefined (no decline) otherwise.
|
|
4664
|
+
*
|
|
4665
|
+
* The cheap registry test runs FIRST so an in-session file — the hot path —
|
|
4666
|
+
* pays one bounded map walk and never the `server.root()` filesystem walks.
|
|
4667
|
+
*/
|
|
4668
|
+
async findOutsideProjectRoot(filePath) {
|
|
4669
|
+
if (!isOutsideAllSessionRoots(filePath))
|
|
4670
|
+
return undefined;
|
|
4671
|
+
const candidates = await Promise.all(getServersForFileWithConfig(filePath).map((server) => server.root(filePath)));
|
|
4672
|
+
return candidates.find((candidate) => Boolean(candidate));
|
|
4673
|
+
}
|
|
4674
|
+
/**
|
|
4675
|
+
* #2052: one bounded record per normalized foreign root per session.
|
|
4676
|
+
*
|
|
4677
|
+
* `operation` is a PARAMETER, not a constant: `touchFile` and
|
|
4678
|
+
* `getDiagnostics` both decline here, and a record that always claimed
|
|
4679
|
+
* "touchFile" would erase which call site actually refused — the
|
|
4680
|
+
* discriminating identity a bounded record has to preserve. The ledger's
|
|
4681
|
+
* rising edge is keyed on the root, so many files under one foreign root
|
|
4682
|
+
* still collapse to a single detailed row.
|
|
4683
|
+
*/
|
|
4684
|
+
recordOutsideRootDecline(filePath, nearestRoot, operation) {
|
|
4685
|
+
const root = normalizeMapKey(nearestRoot);
|
|
4686
|
+
const sessionRoots = getSessionRootsForTelemetry()
|
|
4687
|
+
.map((sessionRoot) => normalizeMapKey(sessionRoot))
|
|
4688
|
+
.join(", ");
|
|
4689
|
+
emitBounded("lsp_capability_skip", root, {
|
|
4690
|
+
filePath: normalizeMapKey(filePath),
|
|
4691
|
+
durationMs: 0,
|
|
4692
|
+
metadata: {
|
|
4693
|
+
operation,
|
|
4694
|
+
nearestMarkerRoot: root,
|
|
4695
|
+
sessionRoots,
|
|
4696
|
+
identity: root,
|
|
4697
|
+
},
|
|
4698
|
+
}, {
|
|
4699
|
+
ledgerKind: "lsp-capability-skip",
|
|
4700
|
+
risingEdgePer: "identity",
|
|
4701
|
+
reason: `declined LSP root outside session cwd: ${root}`,
|
|
4702
|
+
});
|
|
4703
|
+
}
|
|
4495
4704
|
/**
|
|
4496
4705
|
* Get diagnostics for a file
|
|
4497
4706
|
*/
|
|
@@ -4527,6 +4736,11 @@ export class LSPService {
|
|
|
4527
4736
|
}
|
|
4528
4737
|
async getDiagnostics(filePath, diagnosticsMode = "full") {
|
|
4529
4738
|
const normalizedPath = normalizeMapKey(filePath);
|
|
4739
|
+
const outsideRoot = await this.findOutsideProjectRoot(filePath);
|
|
4740
|
+
if (outsideRoot) {
|
|
4741
|
+
this.recordOutsideRootDecline(filePath, outsideRoot, "getDiagnostics");
|
|
4742
|
+
return [];
|
|
4743
|
+
}
|
|
4530
4744
|
if (this.checkDestroyed()) {
|
|
4531
4745
|
this.lastDiagnosticsHealth.set(normalizedPath, {
|
|
4532
4746
|
health: "destroyed",
|
|
@@ -4972,7 +5186,7 @@ export class LSPService {
|
|
|
4972
5186
|
* `executeCommand` above cannot give a render-path probe:
|
|
4973
5187
|
*
|
|
4974
5188
|
* - **Never spawns.** It resolves the already-connected client map the way
|
|
4975
|
-
*
|
|
5189
|
+
* other read-only client-map lookups do, instead of routing through
|
|
4976
5190
|
* `getClientForFile` → `ensureClientForServer`. A probe that spawns a
|
|
4977
5191
|
* language-server fleet to answer a question about how to RENDER a
|
|
4978
5192
|
* diagnostic is a cost the caller never asked for — and under warm attach
|
|
@@ -5180,7 +5394,21 @@ export class LSPService {
|
|
|
5180
5394
|
const activeClients = this.activeClientsForCwd(cwd, priorityServerIds);
|
|
5181
5395
|
const willRenameFailures = [];
|
|
5182
5396
|
const didRenameFailures = [];
|
|
5183
|
-
const
|
|
5397
|
+
const willRenameClients = activeClients.filter(({ serverId, client }) => {
|
|
5398
|
+
if (client.getOperationSupport().willRenameFiles === true)
|
|
5399
|
+
return true;
|
|
5400
|
+
recordDegradationOnce({
|
|
5401
|
+
kind: "lsp-capability-skip",
|
|
5402
|
+
subject: `${serverId}:workspace/willRenameFiles`,
|
|
5403
|
+
reason: client
|
|
5404
|
+
.getMalformedFileOperationRegistrations()
|
|
5405
|
+
.has("willRename")
|
|
5406
|
+
? "malformed-registration"
|
|
5407
|
+
: "no-registration",
|
|
5408
|
+
});
|
|
5409
|
+
return false;
|
|
5410
|
+
});
|
|
5411
|
+
const willResults = await Promise.all(willRenameClients.map(async ({ serverId, client }) => {
|
|
5184
5412
|
try {
|
|
5185
5413
|
return {
|
|
5186
5414
|
serverId,
|
|
@@ -5196,7 +5424,7 @@ export class LSPService {
|
|
|
5196
5424
|
}
|
|
5197
5425
|
}));
|
|
5198
5426
|
const successfulWillResults = willResults.filter((result) => !willRenameFailures.some((failure) => failure.serverId === result.serverId));
|
|
5199
|
-
if (
|
|
5427
|
+
if (willRenameClients.length > 0 && successfulWillResults.length === 0) {
|
|
5200
5428
|
throw new Error(`workspace/willRenameFiles failed for all active LSP servers: ${willRenameFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
|
|
5201
5429
|
}
|
|
5202
5430
|
const merged = mergeWorkspaceTextEditsByPriority(successfulWillResults);
|
|
@@ -5343,6 +5571,21 @@ export class LSPService {
|
|
|
5343
5571
|
path.basename(newFilePath);
|
|
5344
5572
|
const renameDescription = `Renamed ${relOld} → ${relNew}`;
|
|
5345
5573
|
await Promise.all(activeClients.map(async ({ serverId, client }) => {
|
|
5574
|
+
// #1971 review: didRename has its own registration. A server that
|
|
5575
|
+
// never asked for didRenameFiles notifications must not receive
|
|
5576
|
+
// them — same chokepoint discipline as the willRename preflight.
|
|
5577
|
+
if (client.getOperationSupport().didRenameFiles !== true) {
|
|
5578
|
+
recordDegradationOnce({
|
|
5579
|
+
kind: "lsp-capability-skip",
|
|
5580
|
+
subject: `${serverId}:workspace/didRenameFiles`,
|
|
5581
|
+
reason: client
|
|
5582
|
+
.getMalformedFileOperationRegistrations()
|
|
5583
|
+
.has("didRename")
|
|
5584
|
+
? "malformed-registration"
|
|
5585
|
+
: "no-registration",
|
|
5586
|
+
});
|
|
5587
|
+
return;
|
|
5588
|
+
}
|
|
5346
5589
|
const opened = openDocuments.find((entry) => entry.serverId === serverId);
|
|
5347
5590
|
// #1621: bounded for the same reason as the didClose notify above —
|
|
5348
5591
|
// rename propagation is best-effort advice to servers, not a
|
|
@@ -5828,6 +6071,10 @@ export class LSPService {
|
|
|
5828
6071
|
// mtime it was ACTUALLY scanned at (not re-stat'd after the fact, which
|
|
5829
6072
|
// could race a concurrent edit).
|
|
5830
6073
|
const scannedMtimeByFile = new Map();
|
|
6074
|
+
// #2300: size from the SAME stat call as the mtime above — zero extra
|
|
6075
|
+
// syscalls — so the cache write below can give `isEntryFresh` a size
|
|
6076
|
+
// axis alongside the mtime.
|
|
6077
|
+
const scannedSizeByFile = new Map();
|
|
5831
6078
|
// Group files by their primary language server (#387, extracted as
|
|
5832
6079
|
// `groupFilesByPrimaryServer` for #631). tsserver — and most servers — is
|
|
5833
6080
|
// single-threaded per project: N concurrent touches to ONE server don't
|
|
@@ -6018,7 +6265,9 @@ export class LSPService {
|
|
|
6018
6265
|
// microseconds with no extra event-loop tick, where an awaited
|
|
6019
6266
|
// promise would insert one.
|
|
6020
6267
|
try {
|
|
6021
|
-
|
|
6268
|
+
const scanStat = nodeFs.statSync(filePath);
|
|
6269
|
+
scannedMtimeByFile.set(filePath, scanStat.mtimeMs);
|
|
6270
|
+
scannedSizeByFile.set(filePath, scanStat.size);
|
|
6022
6271
|
}
|
|
6023
6272
|
catch {
|
|
6024
6273
|
// Best-effort: a failed stat here just means this file won't be
|
|
@@ -6091,20 +6340,35 @@ export class LSPService {
|
|
|
6091
6340
|
// instead of aggregating as a confirmed clean. Every route is gated here.
|
|
6092
6341
|
const unconfirmedServerIds = touchCoverageGap(touchResult);
|
|
6093
6342
|
const coverageGap = unconfirmedServerIds.length > 0;
|
|
6343
|
+
// #2052: the touch DECLINED this file — it is outside every
|
|
6344
|
+
// initialized session cwd, so nothing was asked and `diags` is an
|
|
6345
|
+
// empty placeholder, not an observation. Before this, the sweep
|
|
6346
|
+
// never read `skipReason`, so a declined foreign file fell through
|
|
6347
|
+
// as `timedOut: false` with zero diagnostics: reported CONFIRMED
|
|
6348
|
+
// CLEAN, and then written into the workspace cache by the record
|
|
6349
|
+
// loop below, which replays that false clean on every later sweep.
|
|
6350
|
+
const declinedOutsideRoot = touchResult?.skipReason === "outside-project-root";
|
|
6094
6351
|
// #1549: the sweep verdict is per answering lane. An auxiliary gap
|
|
6095
6352
|
// narrows coverage, but a primary answer remains usable; only absence of
|
|
6096
6353
|
// the touch result or a primary-scoped inconclusive verdict poisons it.
|
|
6097
|
-
const timedOut = touchResult === undefined || inconclusive;
|
|
6354
|
+
const timedOut = touchResult === undefined || inconclusive || declinedOutsideRoot;
|
|
6098
6355
|
if (timedOut)
|
|
6099
6356
|
timedOutFiles += 1;
|
|
6100
6357
|
// #1618: WHY, in priority order — the outer deadline (nothing came
|
|
6101
6358
|
// back at all) outranks an inner inconclusive signal, which outranks
|
|
6102
6359
|
// a narrower auxiliary coverage gap.
|
|
6360
|
+
// #2052: `outside_project_root` is checked FIRST among the reasons a
|
|
6361
|
+
// touch produced no observation. A declined file cannot be
|
|
6362
|
+
// `inconclusive` (nothing was asked, so nothing timed out), and
|
|
6363
|
+
// rendering it as "didn't complete within budget" would tell the
|
|
6364
|
+
// reader to retry a request that will always be refused.
|
|
6103
6365
|
const unconfirmedReason = touchResult === undefined
|
|
6104
6366
|
? "budget"
|
|
6105
|
-
:
|
|
6106
|
-
? "
|
|
6107
|
-
:
|
|
6367
|
+
: declinedOutsideRoot
|
|
6368
|
+
? "outside_project_root"
|
|
6369
|
+
: inconclusive
|
|
6370
|
+
? "inconclusive"
|
|
6371
|
+
: undefined;
|
|
6108
6372
|
// #1104 (shape 5 — AGENTS.md): the touch's content binding is an
|
|
6109
6373
|
// EXPLICIT enumerable field on the wrapper now (#1179), so it survives
|
|
6110
6374
|
// `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
|
|
@@ -6187,140 +6451,177 @@ export class LSPService {
|
|
|
6187
6451
|
// is the same primitive `tools/lsp-diagnostics.ts`'s batch/directory scan
|
|
6188
6452
|
// now uses for its own file list.
|
|
6189
6453
|
const groupWorkers = Math.min(WORKSPACE_DIAGNOSTICS_CONCURRENCY, groups.length);
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6454
|
+
// #1723 residual, named as a deferred gap on PR #1805 and closed here:
|
|
6455
|
+
// the SCAN-side twin of the runner chokepoint #1805 wired in
|
|
6456
|
+
// `dispatcher.ts`'s `runRunner`. This sweep is the site whose COMPLETION
|
|
6457
|
+
// (`lsp_workspace_diagnostics`) is the `lastPhase` on #1723's own
|
|
6458
|
+
// 18 270 ms reproduction, so until this bracket existed the record could
|
|
6459
|
+
// only ever name the phase that finished BEFORE the block, never the one
|
|
6460
|
+
// burning the time during it.
|
|
6461
|
+
//
|
|
6462
|
+
// ONE bracket for the whole fan-out, not one per file (#2272 review F1).
|
|
6463
|
+
// Per-file bracketing was the first shape and it was inert for exactly
|
|
6464
|
+
// the case above: `phaseFinished` pushes onto a ring capped at
|
|
6465
|
+
// `CLOSED_BRACKET_CAP` (5, clients/latency-logger.ts), while `turn_end`
|
|
6466
|
+
// reads `getPhaseForWindow` AFTER the sweep returns. A 225-file sweep
|
|
6467
|
+
// therefore evicted the blocking file's own bracket unless the block
|
|
6468
|
+
// landed in the last five files, and the survivors were then rejected by
|
|
6469
|
+
// `MIN_PLAUSIBLE_ELAPSED_FRACTION` for being far shorter than the block
|
|
6470
|
+
// window. The phase string is a constant carrying no per-file identity,
|
|
6471
|
+
// so 225 brackets never held more information than one — they only cost
|
|
6472
|
+
// 225 map inserts and destroyed the ring. One bracket keeps the same
|
|
6473
|
+
// discriminating identity, survives to the read point, and spans the
|
|
6474
|
+
// block by construction.
|
|
6475
|
+
//
|
|
6476
|
+
// Paired in `finally`, per `phaseFinished`'s contract: an abandoned
|
|
6477
|
+
// bracket would misattribute every LATER loop_block to this sweep. The
|
|
6478
|
+
// leak guard is what the tests pin: emptying this `finally` reds three
|
|
6479
|
+
// of them. `finally` rather than a trailing call covers a throw that
|
|
6480
|
+
// escapes the fan-out; abort and service-destroyed both RETURN from the
|
|
6481
|
+
// worker callback rather than throwing, so those two paths alone would
|
|
6482
|
+
// not distinguish the two spellings.
|
|
6483
|
+
const sweepPhase = phaseStarted("lsp_workspace_diagnostics_touch");
|
|
6484
|
+
try {
|
|
6485
|
+
await runPerServerGroups(groups, groupWorkers, async (group) => {
|
|
6486
|
+
if (signal?.aborted)
|
|
6487
|
+
return;
|
|
6488
|
+
// #1618: checked before any per-group work (pull attempt, warm-up,
|
|
6489
|
+
// per-file loop) so a service already destroyed when this group
|
|
6490
|
+
// starts never pays for a language-server round trip it cannot get.
|
|
6491
|
+
if (this.checkDestroyed()) {
|
|
6492
|
+
markServiceDestroyed(group.files);
|
|
6493
|
+
return;
|
|
6494
|
+
}
|
|
6495
|
+
// Fast path: one project-wide pull for the whole group (opt-in).
|
|
6496
|
+
if (!isWarmAttached() && workspacePullEnabled && !group.multiServer) {
|
|
6497
|
+
const pulled = await this.tryWorkspacePull(group.files, perFileMs, cacheServedKeys);
|
|
6498
|
+
if (pulled) {
|
|
6499
|
+
// #1782: an explicit zero-diagnostic answer for a file this
|
|
6500
|
+
// sweep served from cache SUPERSEDES that cached replay. Route
|
|
6501
|
+
// it through the same result list every other answer uses, so
|
|
6502
|
+
// the cache write below overwrites the stale entry and the
|
|
6503
|
+
// footer reconcile in `tools/lens-diagnostics.ts` clears the
|
|
6504
|
+
// widget rows — no second eviction path to keep in step.
|
|
6505
|
+
for (const clean of pulled.extraClean) {
|
|
6506
|
+
supersededCacheKeys.add(normalizeMapKey(clean.filePath));
|
|
6224
6507
|
}
|
|
6225
|
-
|
|
6226
|
-
|
|
6508
|
+
for (const result of [...pulled.results, ...pulled.extraClean]) {
|
|
6509
|
+
results.push({
|
|
6510
|
+
...result,
|
|
6511
|
+
writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
|
|
6512
|
+
});
|
|
6513
|
+
// #671: a pull result is always confirmed (see
|
|
6514
|
+
// `tryWorkspacePull`'s doc comment), so it's cache-eligible
|
|
6515
|
+
// too — best-effort stat since the pull already resolved the
|
|
6516
|
+
// diagnostics for this file some time ago.
|
|
6517
|
+
try {
|
|
6518
|
+
const pullStat = nodeFs.statSync(result.filePath);
|
|
6519
|
+
scannedMtimeByFile.set(result.filePath, pullStat.mtimeMs);
|
|
6520
|
+
scannedSizeByFile.set(result.filePath, pullStat.size);
|
|
6521
|
+
}
|
|
6522
|
+
catch {
|
|
6523
|
+
// Not cache-eligible without a confirmed mtime.
|
|
6524
|
+
}
|
|
6227
6525
|
}
|
|
6526
|
+
completed += group.files.length;
|
|
6527
|
+
options.onProgress?.(completed, files.length);
|
|
6528
|
+
return;
|
|
6228
6529
|
}
|
|
6229
|
-
completed += group.files.length;
|
|
6230
|
-
options.onProgress?.(completed, files.length);
|
|
6231
|
-
return;
|
|
6232
6530
|
}
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
},
|
|
6271
|
-
});
|
|
6272
|
-
for (const filePath of group.files) {
|
|
6273
|
-
results.push({
|
|
6274
|
-
filePath,
|
|
6275
|
-
diagnostics: [],
|
|
6276
|
-
count: 0,
|
|
6277
|
-
timedOut: true,
|
|
6278
|
-
unconfirmedReason: "inconclusive",
|
|
6279
|
-
skippedWarmupFailure: true,
|
|
6531
|
+
// #667: warm-check before this group's own per-file loop starts —
|
|
6532
|
+
// cheap/no-op when the group's primary server already demonstrated
|
|
6533
|
+
// readiness (from an earlier sweep, or an earlier group sharing the
|
|
6534
|
+
// same server root, this session); pays one deliberate warm-up round
|
|
6535
|
+
// trip against the group's first file only when genuinely cold. Not
|
|
6536
|
+
// needed above the pull fast path: a `workspace/diagnostic` pull
|
|
6537
|
+
// already covers the WHOLE group with its own generous per-server
|
|
6538
|
+
// budget in one shot — the per-file "first N files eat individual
|
|
6539
|
+
// timeouts" failure mode this fixes doesn't apply there.
|
|
6540
|
+
const first = group.files[0];
|
|
6541
|
+
if (first && !isWarmAttached()) {
|
|
6542
|
+
const warmup = await this.ensureWarmForSweep(first, { signal });
|
|
6543
|
+
if (signal?.aborted)
|
|
6544
|
+
return;
|
|
6545
|
+
// #744: the group's primary server failed warm-up (initial round
|
|
6546
|
+
// trip + one retry both left it cold). Every per-file touch to it
|
|
6547
|
+
// would re-pay its full timeout and time out again, dragging the
|
|
6548
|
+
// whole sweep — the exact wedged-marksman failure mode this closes.
|
|
6549
|
+
// So skip this group's files and record each as UNCONFIRMED
|
|
6550
|
+
// (timedOut + skippedWarmupFailure), never as confirmed-clean `[]`:
|
|
6551
|
+
// the group is keyed by its primary server, so a non-empty
|
|
6552
|
+
// `failedServerIds` means that primary is the one that couldn't warm.
|
|
6553
|
+
if (warmup.failedServerIds.length > 0) {
|
|
6554
|
+
logLatency({
|
|
6555
|
+
type: "phase",
|
|
6556
|
+
phase: "lsp_sweep_group_skipped_warmup",
|
|
6557
|
+
filePath: first,
|
|
6558
|
+
durationMs: 0,
|
|
6559
|
+
metadata: {
|
|
6560
|
+
failedServerIds: warmup.failedServerIds,
|
|
6561
|
+
// #799: distinguishes a fresh warm-up failure from a
|
|
6562
|
+
// negative-cache hit (this sweep never re-attempted warm-up
|
|
6563
|
+
// at all — it was already known cold from earlier this
|
|
6564
|
+
// session).
|
|
6565
|
+
skippedFromCache: warmup.skippedFromCache ?? false,
|
|
6566
|
+
skippedFiles: group.files.length,
|
|
6567
|
+
},
|
|
6280
6568
|
});
|
|
6281
|
-
|
|
6282
|
-
|
|
6569
|
+
for (const filePath of group.files) {
|
|
6570
|
+
results.push({
|
|
6571
|
+
filePath,
|
|
6572
|
+
diagnostics: [],
|
|
6573
|
+
count: 0,
|
|
6574
|
+
timedOut: true,
|
|
6575
|
+
unconfirmedReason: "inconclusive",
|
|
6576
|
+
skippedWarmupFailure: true,
|
|
6577
|
+
});
|
|
6578
|
+
timedOutFiles += 1;
|
|
6579
|
+
completed += 1;
|
|
6580
|
+
}
|
|
6581
|
+
options.onProgress?.(completed, files.length);
|
|
6582
|
+
return;
|
|
6283
6583
|
}
|
|
6284
|
-
|
|
6285
|
-
|
|
6584
|
+
if (warmup.performedWarmup)
|
|
6585
|
+
options.onServerReady?.();
|
|
6286
6586
|
}
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
// chunk's opens still land inside the debounce window and
|
|
6296
|
-
// coalesce into one flush — see `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE`.
|
|
6297
|
-
for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
|
|
6298
|
-
if (signal?.aborted)
|
|
6299
|
-
return;
|
|
6300
|
-
// #1618: a service destroyed WHILE this group's chunk loop was
|
|
6301
|
-
// already running (the group-start check above can't see a
|
|
6302
|
-
// destruction that lands mid-loop) — stop here and mark every
|
|
6303
|
-
// file from this chunk onward, rather than letting the remaining
|
|
6304
|
-
// chunks pay for pre-opens/touches against a torn-down service.
|
|
6305
|
-
if (this.checkDestroyed()) {
|
|
6306
|
-
markServiceDestroyed(group.files.slice(chunkStart));
|
|
6307
|
-
return;
|
|
6308
|
-
}
|
|
6309
|
-
const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
|
|
6310
|
-
await preOpenGroupFiles(chunk);
|
|
6311
|
-
for (const filePath of chunk) {
|
|
6312
|
-
// Honor cancellation between files (#341); already-collected
|
|
6313
|
-
// results are returned as a partial.
|
|
6587
|
+
// #608/#621: batch-open a CHUNK of this group's files before
|
|
6588
|
+
// waiting on diagnostics for any of them individually — see
|
|
6589
|
+
// `preOpenGroupFiles` above. Chunking (rather than the whole
|
|
6590
|
+
// group at once) bounds how much a single burst can dump on
|
|
6591
|
+
// the server's request queue at real project scale, while each
|
|
6592
|
+
// chunk's opens still land inside the debounce window and
|
|
6593
|
+
// coalesce into one flush — see `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE`.
|
|
6594
|
+
for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
|
|
6314
6595
|
if (signal?.aborted)
|
|
6315
6596
|
return;
|
|
6597
|
+
// #1618: a service destroyed WHILE this group's chunk loop was
|
|
6598
|
+
// already running (the group-start check above can't see a
|
|
6599
|
+
// destruction that lands mid-loop) — stop here and mark every
|
|
6600
|
+
// file from this chunk onward, rather than letting the remaining
|
|
6601
|
+
// chunks pay for pre-opens/touches against a torn-down service.
|
|
6316
6602
|
if (this.checkDestroyed()) {
|
|
6317
|
-
markServiceDestroyed(group.files.slice(
|
|
6603
|
+
markServiceDestroyed(group.files.slice(chunkStart));
|
|
6318
6604
|
return;
|
|
6319
6605
|
}
|
|
6320
|
-
|
|
6606
|
+
const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
|
|
6607
|
+
await preOpenGroupFiles(chunk);
|
|
6608
|
+
for (const filePath of chunk) {
|
|
6609
|
+
// Honor cancellation between files (#341); already-collected
|
|
6610
|
+
// results are returned as a partial.
|
|
6611
|
+
if (signal?.aborted)
|
|
6612
|
+
return;
|
|
6613
|
+
if (this.checkDestroyed()) {
|
|
6614
|
+
markServiceDestroyed(group.files.slice(group.files.indexOf(filePath)));
|
|
6615
|
+
return;
|
|
6616
|
+
}
|
|
6617
|
+
await processFile(filePath);
|
|
6618
|
+
}
|
|
6321
6619
|
}
|
|
6322
|
-
}
|
|
6323
|
-
}
|
|
6620
|
+
}, signal);
|
|
6621
|
+
}
|
|
6622
|
+
finally {
|
|
6623
|
+
phaseFinished(sweepPhase);
|
|
6624
|
+
}
|
|
6324
6625
|
// #1618: per-reason tally alongside the flat `timedOutFiles` count (kept
|
|
6325
6626
|
// for the existing `scripts/analyze-pi-lens-logs.mjs` consumer) — a
|
|
6326
6627
|
// dashboard reading only the flat count can no longer mistake 81
|
|
@@ -6386,7 +6687,7 @@ export class LSPService {
|
|
|
6386
6687
|
// boundToCurrentDisk !== false`) could never demote a pull-cached
|
|
6387
6688
|
// entry. Absent `contentHash` (no binding was available) behaves
|
|
6388
6689
|
// exactly as before.
|
|
6389
|
-
workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
|
|
6690
|
+
workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash, scannedSizeByFile.get(result.filePath));
|
|
6390
6691
|
}
|
|
6391
6692
|
workspaceDiagnosticsCacheCtx.persist();
|
|
6392
6693
|
// #1782: drop every cached replay a pull answered clean — returning both
|
|
@@ -6831,27 +7132,52 @@ export class LSPService {
|
|
|
6831
7132
|
return [...primary, ...aux];
|
|
6832
7133
|
}
|
|
6833
7134
|
}
|
|
6834
|
-
|
|
6835
|
-
|
|
7135
|
+
const LSP_PROCESS_FAMILY = "lsp.service";
|
|
7136
|
+
const LSP_PROCESS_VERSION = 1;
|
|
7137
|
+
function lspProcessState() {
|
|
7138
|
+
let incompatibleHandoff;
|
|
7139
|
+
return getProcessSingleton(LSP_PROCESS_FAMILY, LSP_PROCESS_VERSION, () => ({ service: null, generationHandoff: incompatibleHandoff }), (value) => {
|
|
7140
|
+
// Shut down a live incompatible service before replacing its cell.
|
|
7141
|
+
if (!value || typeof value !== "object")
|
|
7142
|
+
return;
|
|
7143
|
+
const candidate = value;
|
|
7144
|
+
const previousHandoff = candidate.generationHandoff;
|
|
7145
|
+
const teardown = candidate.service?.shutdown
|
|
7146
|
+
? candidate.service
|
|
7147
|
+
.shutdown({ fast: true, reason: "process_singleton_reset" })
|
|
7148
|
+
.catch(() => undefined)
|
|
7149
|
+
: undefined;
|
|
7150
|
+
if (previousHandoff && teardown) {
|
|
7151
|
+
incompatibleHandoff = Promise.allSettled([
|
|
7152
|
+
previousHandoff,
|
|
7153
|
+
teardown,
|
|
7154
|
+
]).then(() => undefined);
|
|
7155
|
+
}
|
|
7156
|
+
else {
|
|
7157
|
+
incompatibleHandoff = previousHandoff ?? teardown;
|
|
7158
|
+
}
|
|
7159
|
+
});
|
|
7160
|
+
}
|
|
7161
|
+
function processService() {
|
|
7162
|
+
const state = lspProcessState();
|
|
7163
|
+
if (!state.service)
|
|
7164
|
+
state.service = new LSPService(state.generationHandoff, process.cwd());
|
|
7165
|
+
return state.service;
|
|
7166
|
+
}
|
|
6836
7167
|
/**
|
|
6837
7168
|
* #850: all singleton generations whose teardown is still pending. A new
|
|
6838
7169
|
* generation may be allocated synchronously, but its first spawn waits on this
|
|
6839
7170
|
* handoff so two generations can never own the same server/root concurrently.
|
|
6840
7171
|
*/
|
|
6841
|
-
let globalLSPGenerationHandoff;
|
|
6842
7172
|
export function getLSPService() {
|
|
6843
|
-
|
|
6844
|
-
globalLSPService = new LSPService(globalLSPGenerationHandoff);
|
|
6845
|
-
}
|
|
6846
|
-
return globalLSPService;
|
|
7173
|
+
return processService();
|
|
6847
7174
|
}
|
|
6848
7175
|
/**
|
|
6849
|
-
*
|
|
6850
|
-
*
|
|
6851
|
-
* binary.
|
|
7176
|
+
* Gate-B readiness seam. It reads only the live client map and never spawns,
|
|
7177
|
+
* waits for initialization, or probes a binary.
|
|
6852
7178
|
*/
|
|
6853
|
-
export async function
|
|
6854
|
-
return getLSPService().
|
|
7179
|
+
export async function hasAuxiliaryLspPublishedForRoot(serverId, filePath) {
|
|
7180
|
+
return getLSPService().hasServerPublishedForFileRoot(serverId, filePath);
|
|
6855
7181
|
}
|
|
6856
7182
|
/**
|
|
6857
7183
|
* Cross-layer seam (#1668) for callers outside `lsp/` (bash/write tool-result
|
|
@@ -6874,8 +7200,9 @@ export function resetLSPService(options = {}) {
|
|
|
6874
7200
|
// hide behind a leaked/stuck guard from the generation before it.
|
|
6875
7201
|
clearWorkspaceSweepHoldForSessionStart();
|
|
6876
7202
|
}
|
|
6877
|
-
const
|
|
6878
|
-
|
|
7203
|
+
const state = lspProcessState();
|
|
7204
|
+
const retiringService = state.service;
|
|
7205
|
+
state.service = null;
|
|
6879
7206
|
if (!retiringService)
|
|
6880
7207
|
return;
|
|
6881
7208
|
// shutdown() marks the service destroyed synchronously before its first
|
|
@@ -6884,14 +7211,14 @@ export function resetLSPService(options = {}) {
|
|
|
6884
7211
|
// its predecessor. allSettled keeps teardown best-effort without ever
|
|
6885
7212
|
// rejecting (and therefore permanently poisoning) the next generation.
|
|
6886
7213
|
const teardown = retiringService.shutdown(options);
|
|
6887
|
-
const pending =
|
|
6888
|
-
? [
|
|
7214
|
+
const pending = state.generationHandoff
|
|
7215
|
+
? [state.generationHandoff, teardown]
|
|
6889
7216
|
: [teardown];
|
|
6890
7217
|
const handoff = Promise.allSettled(pending).then(() => undefined);
|
|
6891
|
-
|
|
7218
|
+
state.generationHandoff = handoff;
|
|
6892
7219
|
void handoff.then(() => {
|
|
6893
|
-
if (
|
|
6894
|
-
|
|
7220
|
+
if (state.generationHandoff === handoff) {
|
|
7221
|
+
state.generationHandoff = undefined;
|
|
6895
7222
|
}
|
|
6896
7223
|
});
|
|
6897
7224
|
}
|