pi-lens 3.8.73 → 4.0.0

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