pi-lens 4.2.1 → 4.4.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 (671) hide show
  1. package/CHANGELOG.md +1562 -0
  2. package/README.md +157 -24
  3. package/config/dependency-cruiser-eager-allowlist.json +6 -2
  4. package/dist/chunk-22AVIGXS.js +560 -0
  5. package/dist/chunk-27BBHSZC.js +106 -0
  6. package/dist/chunk-2BEMIWEW.js +51 -0
  7. package/dist/chunk-2EECBNC5.js +89 -0
  8. package/dist/chunk-34U7PLGE.js +23 -0
  9. package/dist/chunk-3I5QFT3N.js +181 -0
  10. package/dist/chunk-3MED75HT.js +37 -0
  11. package/dist/chunk-47D2BO4S.js +87 -0
  12. package/dist/chunk-4RERDEXR.js +348 -0
  13. package/dist/chunk-4W2PLC4J.js +27 -0
  14. package/dist/chunk-52H5B5TC.js +202 -0
  15. package/dist/chunk-54B42YRX.js +27 -0
  16. package/dist/chunk-5RXBNSW2.js +266 -0
  17. package/dist/chunk-5THXD6X5.js +201 -0
  18. package/dist/chunk-5W3R6LFT.js +24 -0
  19. package/dist/chunk-5ZEJHV35.js +341 -0
  20. package/dist/chunk-6P62QVEA.js +118 -0
  21. package/dist/chunk-6QIUX4EP.js +459 -0
  22. package/dist/chunk-6VCD4VKZ.js +78 -0
  23. package/dist/chunk-7F72PXY2.js +326 -0
  24. package/dist/chunk-7FBF7NPR.js +998 -0
  25. package/dist/chunk-7U2IDNXN.js +37 -0
  26. package/dist/chunk-ABBOA7UD.js +157 -0
  27. package/dist/chunk-AHDW3GPE.js +50 -0
  28. package/dist/chunk-AJ3QXTRQ.js +756 -0
  29. package/dist/chunk-AJVLWNFN.js +1610 -0
  30. package/dist/chunk-AJXODZY6.js +112 -0
  31. package/dist/chunk-ARFCXG7S.js +84 -0
  32. package/dist/chunk-AS44CFQ4.js +708 -0
  33. package/dist/chunk-AWE5TMPO.js +38 -0
  34. package/dist/chunk-B3I2UUT4.js +4692 -0
  35. package/dist/chunk-BRJKOBZP.js +331 -0
  36. package/dist/chunk-BW5KFIQC.js +59 -0
  37. package/dist/chunk-C2AUYXQH.js +149 -0
  38. package/dist/chunk-CGGAIP7J.js +29 -0
  39. package/dist/chunk-D6QS3U6M.js +30 -0
  40. package/dist/chunk-DFVMIKMQ.js +349 -0
  41. package/dist/chunk-DIULIWKU.js +169 -0
  42. package/dist/chunk-DQ2NZ7C4.js +2317 -0
  43. package/dist/chunk-E2WFBRHM.js +213 -0
  44. package/dist/chunk-E4MAEDPM.js +50 -0
  45. package/dist/chunk-EOKRQ3SA.js +30 -0
  46. package/dist/chunk-F3AU2TMM.js +2626 -0
  47. package/dist/chunk-F3P7VQ7H.js +170 -0
  48. package/dist/chunk-FC4RP4I2.js +12 -0
  49. package/dist/chunk-FJNKANOS.js +32 -0
  50. package/dist/chunk-GUOGVTCQ.js +146 -0
  51. package/dist/chunk-GVXA6CCE.js +18 -0
  52. package/dist/chunk-HHYAELLJ.js +2334 -0
  53. package/dist/chunk-HMFXILPV.js +83 -0
  54. package/dist/chunk-HVUZXSWK.js +421 -0
  55. package/dist/chunk-HWBZIGMJ.js +62 -0
  56. package/dist/chunk-I3R7GKDR.js +17 -0
  57. package/dist/chunk-I7SPGOWI.js +55 -0
  58. package/dist/chunk-IIXOAGHR.js +38 -0
  59. package/dist/chunk-IVP5KAB7.js +2250 -0
  60. package/dist/chunk-IXEBJSUE.js +22 -0
  61. package/dist/chunk-JOLITGCC.js +27 -0
  62. package/dist/chunk-K6VYLB2U.js +145 -0
  63. package/dist/chunk-LJDODBBZ.js +78 -0
  64. package/dist/chunk-LNAKEO63.js +78 -0
  65. package/dist/chunk-LSMJ6KBE.js +4139 -0
  66. package/dist/chunk-LW6XKDZQ.js +5565 -0
  67. package/dist/chunk-N3YQJI6O.js +899 -0
  68. package/dist/chunk-NABLC7W3.js +148 -0
  69. package/dist/chunk-NKOUFPQF.js +659 -0
  70. package/dist/chunk-NL5GLMFI.js +211 -0
  71. package/dist/chunk-NMABWBZN.js +327 -0
  72. package/dist/chunk-NTHH2J74.js +27 -0
  73. package/dist/chunk-NTUJEGDZ.js +59 -0
  74. package/dist/chunk-NXL4FFQQ.js +47 -0
  75. package/dist/chunk-NXPS2L6U.js +466 -0
  76. package/dist/chunk-O5WILGMI.js +42 -0
  77. package/dist/chunk-O6TQT6RI.js +48 -0
  78. package/dist/chunk-OAXNDZJ7.js +323 -0
  79. package/dist/chunk-ODEEOXPH.js +44 -0
  80. package/dist/chunk-OQGFCZJ4.js +4533 -0
  81. package/dist/chunk-ORTMY4Q4.js +282 -0
  82. package/dist/chunk-OS32RNR2.js +442 -0
  83. package/dist/chunk-PAK6IDME.js +189 -0
  84. package/dist/chunk-PWFHBTSF.js +406 -0
  85. package/dist/chunk-Q5U6FMDI.js +2352 -0
  86. package/dist/chunk-QOZXJ4NA.js +3364 -0
  87. package/dist/chunk-QYF5S6P6.js +45 -0
  88. package/dist/chunk-RIISMHKN.js +36 -0
  89. package/dist/chunk-RPD4ZSB7.js +25 -0
  90. package/dist/chunk-RPD5K5NC.js +79 -0
  91. package/dist/chunk-RUOG5D2C.js +262 -0
  92. package/dist/chunk-T4ERT73Y.js +599 -0
  93. package/dist/chunk-T6D4Q4AQ.js +54 -0
  94. package/dist/chunk-TGPXLY2J.js +391 -0
  95. package/dist/chunk-TLGFBM3F.js +12116 -0
  96. package/dist/chunk-TO6WL3GM.js +88 -0
  97. package/dist/chunk-TT22N74J.js +517 -0
  98. package/dist/chunk-TVN5N6GY.js +21216 -0
  99. package/dist/chunk-U3VGR4VU.js +308 -0
  100. package/dist/chunk-UK3CMEAL.js +1250 -0
  101. package/dist/chunk-UOPVJQ4N.js +103 -0
  102. package/dist/chunk-UXVNL3D7.js +23 -0
  103. package/dist/chunk-UY752QSN.js +45 -0
  104. package/dist/chunk-V5PXFSZK.js +188 -0
  105. package/dist/chunk-V7R425XG.js +398 -0
  106. package/dist/chunk-VCSRSYLF.js +81 -0
  107. package/dist/chunk-VD4JQCQC.js +162 -0
  108. package/dist/chunk-VDBPVIKD.js +62 -0
  109. package/dist/chunk-VHQLPIRJ.js +114 -0
  110. package/dist/chunk-VN2AXLEX.js +5414 -0
  111. package/dist/chunk-VNMT7C64.js +478 -0
  112. package/dist/chunk-WONERJTP.js +309 -0
  113. package/dist/chunk-XECLWQZN.js +85 -0
  114. package/dist/chunk-XIMYZ3JA.js +27 -0
  115. package/dist/chunk-XK4CIILI.js +144 -0
  116. package/dist/chunk-Y4T27COV.js +562 -0
  117. package/dist/chunk-YGVYKNSN.js +1860 -0
  118. package/dist/chunk-YLF3PPT5.js +308 -0
  119. package/dist/chunk-YSNFMJKN.js +14 -0
  120. package/dist/chunk-Z3CCVDUB.js +76 -0
  121. package/dist/chunk-Z54ZSOBZ.js +61 -0
  122. package/dist/chunk-ZAWW3AF6.js +97 -0
  123. package/dist/chunk-ZMOL6RQC.js +167 -0
  124. package/dist/index.js +19039 -8956
  125. package/dist/mcp/analyze-cli.js +186 -228
  126. package/dist/mcp/cli.js +137 -98
  127. package/dist/mcp/server.js +22592 -1564
  128. package/dist/mcp/worker.js +97 -38
  129. package/dist/probes/installer.js +135 -0
  130. package/dist/workers/project-snapshot-persist-worker.js +22 -0
  131. package/dist/workers/review-graph-persist-worker.js +15 -0
  132. package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  133. package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  134. package/docs/adr/0003-git-guard-latch-writer.md +203 -0
  135. package/docs/adr/0004-disposition-policy-seam.md +38 -0
  136. package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  137. package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  138. package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  139. package/docs/adr/0008-turn-end-lane-interface.md +165 -0
  140. package/docs/adr/0009-reported-path-attribution.md +114 -0
  141. package/docs/adr/README.md +26 -0
  142. package/docs/agent-guide.md +13 -4
  143. package/docs/agent-tools.md +60 -1
  144. package/docs/ast-grep_rules_catalog.md +6 -4
  145. package/docs/configuration.md +75 -2
  146. package/docs/engineering-principles.md +202 -0
  147. package/docs/environment-variables.md +113 -6
  148. package/docs/features.md +10 -6
  149. package/docs/globalconfig.md +7 -1
  150. package/docs/io-bridge-v2-migration.md +94 -0
  151. package/docs/language-coverage.md +17 -0
  152. package/docs/lsp-capability-matrix.md +131 -32
  153. package/docs/lsp-idle-eviction.md +124 -0
  154. package/docs/mcp.md +21 -5
  155. package/docs/merge-queue-rollout.md +77 -0
  156. package/docs/pi-lens-fixer.md +161 -39
  157. package/docs/pi-lens-investigator.md +77 -20
  158. package/docs/pi-lens-merge-policy.md +694 -0
  159. package/docs/pi-lens-monitor.md +73 -7
  160. package/docs/pi-lens-retro.md +6 -4
  161. package/docs/pi-lens-reviewer.md +190 -34
  162. package/docs/pi-lens-subagent.md +247 -37
  163. package/docs/pi-lens-warden.md +18 -7
  164. package/docs/public-api-stability.md +56 -0
  165. package/docs/release-qa-baseline.md +86 -17
  166. package/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
  167. package/docs/servercapabilities.md +66 -52
  168. package/docs/settings.md +13 -3
  169. package/docs/subagent-compat.md +1 -1
  170. package/docs/usage.md +5 -1
  171. package/grammars/tree-sitter-bash.wasm +0 -0
  172. package/grammars/tree-sitter-bash.wasm.json +3 -3
  173. package/package.json +23 -9
  174. package/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
  175. package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  176. package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  177. package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  178. package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  179. package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  180. package/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
  181. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  182. package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  183. package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  184. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  185. package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  186. package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  187. package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  188. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  189. package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  190. package/scripts/analyze-pi-lens-logs.mjs +1275 -62
  191. package/scripts/download-grammars.js +5 -0
  192. package/scripts/grammars.lock.json +6 -1
  193. package/scripts/install-selftest.mjs +38 -35
  194. package/scripts/lib/host-provided-deps.mjs +13 -4
  195. package/scripts/lib/web-tree-sitter-dir.mjs +141 -0
  196. package/dist/clients/actionable-warnings-logger.js +0 -35
  197. package/dist/clients/actionable-warnings.js +0 -1594
  198. package/dist/clients/advisory-provenance.js +0 -457
  199. package/dist/clients/agent-behavior-client.js +0 -147
  200. package/dist/clients/agent-nudge.js +0 -325
  201. package/dist/clients/analysed-root.js +0 -1
  202. package/dist/clients/ast-grep-client.js +0 -731
  203. package/dist/clients/ast-grep-rule-manager.js +0 -146
  204. package/dist/clients/ast-grep-tool-logger.js +0 -118
  205. package/dist/clients/ast-grep-types.js +0 -10
  206. package/dist/clients/ast-grep-yaml-synth.js +0 -154
  207. package/dist/clients/atomic-write-staging.js +0 -75
  208. package/dist/clients/atomic-write.js +0 -189
  209. package/dist/clients/availability-probe-flight.js +0 -12
  210. package/dist/clients/bash-file-access.js +0 -1056
  211. package/dist/clients/biome-client.js +0 -357
  212. package/dist/clients/blocker-freshness.js +0 -837
  213. package/dist/clients/blocker-past-eof.js +0 -129
  214. package/dist/clients/bootstrap.js +0 -569
  215. package/dist/clients/bounded-cache.js +0 -190
  216. package/dist/clients/bounded-pid-file-lock.js +0 -202
  217. package/dist/clients/bounded-telemetry.js +0 -295
  218. package/dist/clients/build-identity.js +0 -95
  219. package/dist/clients/bundled-resource-health.js +0 -113
  220. package/dist/clients/bus-events-logger.js +0 -105
  221. package/dist/clients/bus-publish.js +0 -171
  222. package/dist/clients/cache/rule-cache.js +0 -190
  223. package/dist/clients/cache-manager.js +0 -465
  224. package/dist/clients/cache-observability.js +0 -1313
  225. package/dist/clients/call-graph.js +0 -852
  226. package/dist/clients/cargo-manifest.js +0 -422
  227. package/dist/clients/cascade-budget.js +0 -196
  228. package/dist/clients/cascade-format.js +0 -315
  229. package/dist/clients/cascade-logger.js +0 -31
  230. package/dist/clients/cascade-types.js +0 -9
  231. package/dist/clients/child-unref.js +0 -115
  232. package/dist/clients/code-quality-warnings.js +0 -286
  233. package/dist/clients/codebase-model.js +0 -173
  234. package/dist/clients/collateral-test-role.js +0 -40
  235. package/dist/clients/complexity-client.js +0 -462
  236. package/dist/clients/config-core/deny.js +0 -221
  237. package/dist/clients/config-core/index.js +0 -50
  238. package/dist/clients/config-core/merge.js +0 -357
  239. package/dist/clients/config-core/normalize.js +0 -340
  240. package/dist/clients/config-core/process-spec.js +0 -248
  241. package/dist/clients/config-core/provenance.js +0 -164
  242. package/dist/clients/config-core/records.js +0 -218
  243. package/dist/clients/config-core/resolve.js +0 -125
  244. package/dist/clients/config-core/safe-object.js +0 -78
  245. package/dist/clients/config-core/schema.js +0 -165
  246. package/dist/clients/config-diagnostic-codes.js +0 -283
  247. package/dist/clients/config-locations.js +0 -160
  248. package/dist/clients/config-resolve.js +0 -792
  249. package/dist/clients/config-schema.js +0 -251
  250. package/dist/clients/config-warn.js +0 -407
  251. package/dist/clients/console-guard-install.js +0 -26
  252. package/dist/clients/cooperative-budget.js +0 -42
  253. package/dist/clients/dead-code-client.js +0 -510
  254. package/dist/clients/dead-code-logger.js +0 -30
  255. package/dist/clients/deadline-utils.js +0 -280
  256. package/dist/clients/debug-handles.js +0 -211
  257. package/dist/clients/debug-heap.js +0 -174
  258. package/dist/clients/deferred-lsp-work.js +0 -106
  259. package/dist/clients/degradation-ledger.js +0 -396
  260. package/dist/clients/demoted-finding-render.js +0 -169
  261. package/dist/clients/dependency-checker.js +0 -862
  262. package/dist/clients/deps/ast-grep-napi.js +0 -22
  263. package/dist/clients/deps/js-yaml.js +0 -9
  264. package/dist/clients/deps/minimatch.js +0 -4
  265. package/dist/clients/deps/pi-tui.js +0 -14
  266. package/dist/clients/deps/typebox.js +0 -14
  267. package/dist/clients/deps/vscode-jsonrpc.js +0 -1
  268. package/dist/clients/deps/web-tree-sitter.js +0 -24
  269. package/dist/clients/diagnostic-dispositions.js +0 -594
  270. package/dist/clients/diagnostic-line-freshness.js +0 -305
  271. package/dist/clients/diagnostic-logger.js +0 -73
  272. package/dist/clients/diagnostic-tracker.js +0 -105
  273. package/dist/clients/diagnostics-publish.js +0 -230
  274. package/dist/clients/dispatch/ast-grep-catalog.js +0 -65
  275. package/dist/clients/dispatch/auxiliary-lsp.js +0 -343
  276. package/dist/clients/dispatch/collect-later-tier.js +0 -35
  277. package/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
  278. package/dist/clients/dispatch/dispatcher.js +0 -1269
  279. package/dist/clients/dispatch/fact-provider-types.js +0 -1
  280. package/dist/clients/dispatch/fact-rule-runner.js +0 -17
  281. package/dist/clients/dispatch/fact-runner.js +0 -30
  282. package/dist/clients/dispatch/fact-scheduler.js +0 -68
  283. package/dist/clients/dispatch/fact-store.js +0 -377
  284. package/dist/clients/dispatch/facts/comment-facts.js +0 -22
  285. package/dist/clients/dispatch/facts/file-content.js +0 -19
  286. package/dist/clients/dispatch/facts/function-facts.js +0 -348
  287. package/dist/clients/dispatch/facts/import-facts.js +0 -204
  288. package/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
  289. package/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
  290. package/dist/clients/dispatch/finding-policy.js +0 -222
  291. package/dist/clients/dispatch/indent-detect.js +0 -411
  292. package/dist/clients/dispatch/inline-suppressions.js +0 -70
  293. package/dist/clients/dispatch/integration.js +0 -2377
  294. package/dist/clients/dispatch/lazy.js +0 -11
  295. package/dist/clients/dispatch/pending-runner-findings.js +0 -87
  296. package/dist/clients/dispatch/plan.js +0 -278
  297. package/dist/clients/dispatch/priorities.js +0 -20
  298. package/dist/clients/dispatch/rule-id-normalize.js +0 -50
  299. package/dist/clients/dispatch/rule-ignores.js +0 -62
  300. package/dist/clients/dispatch/rule-policy.js +0 -135
  301. package/dist/clients/dispatch/rules/async-noise.js +0 -40
  302. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
  303. package/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
  304. package/dist/clients/dispatch/rules/error-obscuring.js +0 -32
  305. package/dist/clients/dispatch/rules/error-swallowing.js +0 -31
  306. package/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
  307. package/dist/clients/dispatch/rules/high-complexity.js +0 -72
  308. package/dist/clients/dispatch/rules/high-fan-out.js +0 -74
  309. package/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
  310. package/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
  311. package/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
  312. package/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
  313. package/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
  314. package/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
  315. package/dist/clients/dispatch/runner-context.js +0 -52
  316. package/dist/clients/dispatch/runners/actionlint.js +0 -113
  317. package/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
  318. package/dist/clients/dispatch/runners/biome-check.js +0 -283
  319. package/dist/clients/dispatch/runners/cpp-check.js +0 -236
  320. package/dist/clients/dispatch/runners/credo.js +0 -80
  321. package/dist/clients/dispatch/runners/cue-vet.js +0 -398
  322. package/dist/clients/dispatch/runners/dart-analyze.js +0 -202
  323. package/dist/clients/dispatch/runners/detekt.js +0 -169
  324. package/dist/clients/dispatch/runners/dotnet-build.js +0 -159
  325. package/dist/clients/dispatch/runners/elixir-check.js +0 -210
  326. package/dist/clients/dispatch/runners/eslint.js +0 -154
  327. package/dist/clients/dispatch/runners/fact-rules.js +0 -29
  328. package/dist/clients/dispatch/runners/fish-indent.js +0 -69
  329. package/dist/clients/dispatch/runners/gleam-check.js +0 -87
  330. package/dist/clients/dispatch/runners/go-vet.js +0 -90
  331. package/dist/clients/dispatch/runners/golangci-lint.js +0 -120
  332. package/dist/clients/dispatch/runners/hadolint.js +0 -79
  333. package/dist/clients/dispatch/runners/helm-lint.js +0 -171
  334. package/dist/clients/dispatch/runners/helm-render.js +0 -940
  335. package/dist/clients/dispatch/runners/htmlhint.js +0 -108
  336. package/dist/clients/dispatch/runners/index.js +0 -108
  337. package/dist/clients/dispatch/runners/javac.js +0 -89
  338. package/dist/clients/dispatch/runners/ktlint.js +0 -136
  339. package/dist/clients/dispatch/runners/lsp.js +0 -378
  340. package/dist/clients/dispatch/runners/markdownlint.js +0 -160
  341. package/dist/clients/dispatch/runners/mypy.js +0 -101
  342. package/dist/clients/dispatch/runners/oxlint.js +0 -398
  343. package/dist/clients/dispatch/runners/php-lint.js +0 -62
  344. package/dist/clients/dispatch/runners/phpstan.js +0 -120
  345. package/dist/clients/dispatch/runners/prisma-validate.js +0 -62
  346. package/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
  347. package/dist/clients/dispatch/runners/pyright.js +0 -169
  348. package/dist/clients/dispatch/runners/rubocop.js +0 -90
  349. package/dist/clients/dispatch/runners/ruff.js +0 -89
  350. package/dist/clients/dispatch/runners/rust-clippy.js +0 -251
  351. package/dist/clients/dispatch/runners/shellcheck.js +0 -169
  352. package/dist/clients/dispatch/runners/shfmt.js +0 -102
  353. package/dist/clients/dispatch/runners/spellcheck.js +0 -121
  354. package/dist/clients/dispatch/runners/spotbugs.js +0 -259
  355. package/dist/clients/dispatch/runners/sqlfluff.js +0 -174
  356. package/dist/clients/dispatch/runners/stylelint.js +0 -158
  357. package/dist/clients/dispatch/runners/swiftlint.js +0 -154
  358. package/dist/clients/dispatch/runners/taplo.js +0 -168
  359. package/dist/clients/dispatch/runners/terragrunt.js +0 -168
  360. package/dist/clients/dispatch/runners/tflint.js +0 -110
  361. package/dist/clients/dispatch/runners/tree-sitter.js +0 -620
  362. package/dist/clients/dispatch/runners/trivy-config.js +0 -247
  363. package/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
  364. package/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
  365. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -85
  366. package/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
  367. package/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
  368. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
  369. package/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
  370. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
  371. package/dist/clients/dispatch/runners/utils.js +0 -15
  372. package/dist/clients/dispatch/runners/vale.js +0 -116
  373. package/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
  374. package/dist/clients/dispatch/runners/yamllint.js +0 -85
  375. package/dist/clients/dispatch/runners/zig-check.js +0 -91
  376. package/dist/clients/dispatch/suppress-writer.js +0 -69
  377. package/dist/clients/dispatch/tool-profile.js +0 -30
  378. package/dist/clients/dispatch/types.js +0 -21
  379. package/dist/clients/dispatch/utils/format-utils.js +0 -45
  380. package/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
  381. package/dist/clients/disposition-logger.js +0 -32
  382. package/dist/clients/disposition-publish.js +0 -124
  383. package/dist/clients/durable-store.js +0 -97
  384. package/dist/clients/effective-config.js +0 -413
  385. package/dist/clients/env-utils.js +0 -83
  386. package/dist/clients/error-class.js +0 -23
  387. package/dist/clients/eval-timestamp.js +0 -17
  388. package/dist/clients/event-loop-hold.js +0 -274
  389. package/dist/clients/event-loop-monitor.js +0 -250
  390. package/dist/clients/extension-log.js +0 -504
  391. package/dist/clients/extension-mode.js +0 -73
  392. package/dist/clients/feature-hints.js +0 -103
  393. package/dist/clients/file-kinds.js +0 -412
  394. package/dist/clients/file-role.js +0 -134
  395. package/dist/clients/file-time.js +0 -160
  396. package/dist/clients/file-utils.js +0 -1032
  397. package/dist/clients/finding-delivery-gate.js +0 -463
  398. package/dist/clients/finding-identity.js +0 -88
  399. package/dist/clients/fix-worklog.js +0 -103
  400. package/dist/clients/format-events-publish.js +0 -343
  401. package/dist/clients/format-service.js +0 -222
  402. package/dist/clients/formatters-lazy.js +0 -9
  403. package/dist/clients/formatters.js +0 -1994
  404. package/dist/clients/freshness-cadence.js +0 -17
  405. package/dist/clients/freshness.js +0 -38
  406. package/dist/clients/generated-artifacts.js +0 -394
  407. package/dist/clients/generation-guard.js +0 -215
  408. package/dist/clients/git-guard.js +0 -923
  409. package/dist/clients/git-tracked-ignore.js +0 -243
  410. package/dist/clients/gitleaks-client.js +0 -509
  411. package/dist/clients/go-client.js +0 -104
  412. package/dist/clients/govulncheck-client.js +0 -605
  413. package/dist/clients/gradle-ktfmt-style.js +0 -252
  414. package/dist/clients/grammar-source.js +0 -465
  415. package/dist/clients/gzip-stage-write.js +0 -135
  416. package/dist/clients/hashline-anchor.js +0 -424
  417. package/dist/clients/hook-budgets.js +0 -76
  418. package/dist/clients/host-edit-normalize.js +0 -140
  419. package/dist/clients/host-ports.js +0 -39
  420. package/dist/clients/indent-retarget.js +0 -154
  421. package/dist/clients/install-diagnostics.js +0 -143
  422. package/dist/clients/installer/index.js +0 -5167
  423. package/dist/clients/installer/managed-tool-refresh-session.js +0 -58
  424. package/dist/clients/installer/managed-tool-refresh.js +0 -783
  425. package/dist/clients/instance-reaper-state.js +0 -7
  426. package/dist/clients/instance-reaper.js +0 -1137
  427. package/dist/clients/instance-registry-lock.js +0 -145
  428. package/dist/clients/instance-registry.js +0 -773
  429. package/dist/clients/jscpd-client.js +0 -370
  430. package/dist/clients/json-cache-read.js +0 -65
  431. package/dist/clients/knip-client.js +0 -788
  432. package/dist/clients/language-policy.js +0 -252
  433. package/dist/clients/language-profile.js +0 -361
  434. package/dist/clients/language-registry.js +0 -603
  435. package/dist/clients/latency-logger.js +0 -719
  436. package/dist/clients/lazy-import.js +0 -21
  437. package/dist/clients/ledger-bounds.js +0 -54
  438. package/dist/clients/lens-config.js +0 -346
  439. package/dist/clients/lens-engine.js +0 -358
  440. package/dist/clients/lens-events.js +0 -125
  441. package/dist/clients/lens-flag-registry.js +0 -467
  442. package/dist/clients/lens-map.js +0 -1156
  443. package/dist/clients/live-bus-emitter.js +0 -74
  444. package/dist/clients/log-cleanup.js +0 -222
  445. package/dist/clients/lsp/aggregation.js +0 -178
  446. package/dist/clients/lsp/cascade-tier.js +0 -421
  447. package/dist/clients/lsp/client.js +0 -4465
  448. package/dist/clients/lsp/config.js +0 -689
  449. package/dist/clients/lsp/diagnostic-binding.js +0 -225
  450. package/dist/clients/lsp/document-drift.js +0 -467
  451. package/dist/clients/lsp/edits.js +0 -1219
  452. package/dist/clients/lsp/index.js +0 -8305
  453. package/dist/clients/lsp/inferred-project.js +0 -216
  454. package/dist/clients/lsp/jvm-runtime.js +0 -298
  455. package/dist/clients/lsp/language.js +0 -63
  456. package/dist/clients/lsp/launch.js +0 -725
  457. package/dist/clients/lsp/lombok.js +0 -158
  458. package/dist/clients/lsp/path-utils.js +0 -5
  459. package/dist/clients/lsp/pending-aux-coverage.js +0 -251
  460. package/dist/clients/lsp/position-encoding.js +0 -61
  461. package/dist/clients/lsp/ruby-drive-dirs.js +0 -83
  462. package/dist/clients/lsp/server.js +0 -3209
  463. package/dist/clients/lsp/session-roots.js +0 -192
  464. package/dist/clients/lsp/spawn-history.js +0 -17
  465. package/dist/clients/lsp/sync-kind.js +0 -42
  466. package/dist/clients/lsp/tsserver-sync.js +0 -332
  467. package/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
  468. package/dist/clients/lsp/wait-policy/classification.js +0 -85
  469. package/dist/clients/lsp/wait-policy/index.js +0 -10
  470. package/dist/clients/lsp/wait-policy/strategies.js +0 -303
  471. package/dist/clients/lsp/watch-queue.js +0 -61
  472. package/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
  473. package/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
  474. package/dist/clients/lsp/workspace-sweep-hold.js +0 -184
  475. package/dist/clients/lsp-budget.js +0 -186
  476. package/dist/clients/lsp-document-symbols.js +0 -110
  477. package/dist/clients/lsp-lazy.js +0 -9
  478. package/dist/clients/lsp-mutation.js +0 -396
  479. package/dist/clients/lsp-status.js +0 -59
  480. package/dist/clients/map-with-concurrency.js +0 -40
  481. package/dist/clients/mcp/analyze.js +0 -420
  482. package/dist/clients/mcp/host-shim.js +0 -31
  483. package/dist/clients/mcp/ipc.js +0 -335
  484. package/dist/clients/mcp/review.js +0 -151
  485. package/dist/clients/mcp/session.js +0 -412
  486. package/dist/clients/memory-sampler.js +0 -208
  487. package/dist/clients/message-end-attribution.js +0 -30
  488. package/dist/clients/metrics-client.js +0 -107
  489. package/dist/clients/metrics-history.js +0 -327
  490. package/dist/clients/middle-man-analysis.js +0 -263
  491. package/dist/clients/model-provider.js +0 -53
  492. package/dist/clients/module-report-lsp.js +0 -189
  493. package/dist/clients/module-report.js +0 -2150
  494. package/dist/clients/mutating-tool.js +0 -651
  495. package/dist/clients/mutation-attribution.js +0 -368
  496. package/dist/clients/mutation-bridge.js +0 -240
  497. package/dist/clients/ndjson-logger.js +0 -734
  498. package/dist/clients/observed-mutation-sources.js +0 -101
  499. package/dist/clients/observed-mutation.js +0 -1253
  500. package/dist/clients/oldtext-autopatch.js +0 -128
  501. package/dist/clients/opaque-mutation-scan.js +0 -496
  502. package/dist/clients/opengrep-client.js +0 -328
  503. package/dist/clients/opengrep-config.js +0 -76
  504. package/dist/clients/package-manager.js +0 -607
  505. package/dist/clients/package-root.js +0 -38
  506. package/dist/clients/partial-edit-apply.js +0 -439
  507. package/dist/clients/path-attribution-telemetry.js +0 -43
  508. package/dist/clients/path-keyed-map.js +0 -108
  509. package/dist/clients/path-utils.js +0 -1244
  510. package/dist/clients/performance-report.js +0 -331
  511. package/dist/clients/persist-debounce.js +0 -70
  512. package/dist/clients/persistent-reverify.js +0 -271
  513. package/dist/clients/php-cs-fixer-config.js +0 -119
  514. package/dist/clients/pipeline.js +0 -1425
  515. package/dist/clients/post-autofix-notice.js +0 -37
  516. package/dist/clients/print-mode.js +0 -21
  517. package/dist/clients/probe-home-state.js +0 -228
  518. package/dist/clients/process-bridge.js +0 -66
  519. package/dist/clients/process-singletons.js +0 -210
  520. package/dist/clients/process-snapshot.js +0 -68
  521. package/dist/clients/project-changes.js +0 -181
  522. package/dist/clients/project-conventions.js +0 -171
  523. package/dist/clients/project-diagnostics/cache.js +0 -137
  524. package/dist/clients/project-diagnostics/extractors.js +0 -109
  525. package/dist/clients/project-diagnostics/fresh-fetch.js +0 -650
  526. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
  527. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
  528. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
  529. package/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
  530. package/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
  531. package/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
  532. package/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
  533. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
  534. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
  535. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
  536. package/dist/clients/project-diagnostics/scanner.js +0 -520
  537. package/dist/clients/project-diagnostics/types.js +0 -1
  538. package/dist/clients/project-lens-config.js +0 -799
  539. package/dist/clients/project-report.js +0 -701
  540. package/dist/clients/project-scale.js +0 -237
  541. package/dist/clients/project-scan-policy.js +0 -11
  542. package/dist/clients/project-snapshot-fingerprint.js +0 -48
  543. package/dist/clients/project-snapshot-persist-worker.js +0 -20
  544. package/dist/clients/project-snapshot.js +0 -1653
  545. package/dist/clients/project-trust.js +0 -181
  546. package/dist/clients/python-environment.js +0 -217
  547. package/dist/clients/python-provenance.js +0 -639
  548. package/dist/clients/quiet-window-config.js +0 -47
  549. package/dist/clients/quiet-window.js +0 -236
  550. package/dist/clients/read-bridge.js +0 -139
  551. package/dist/clients/read-expansion.js +0 -266
  552. package/dist/clients/read-guard-logger.js +0 -251
  553. package/dist/clients/read-guard-tool-lines.js +0 -1306
  554. package/dist/clients/read-guard.js +0 -1561
  555. package/dist/clients/recent-touches.js +0 -240
  556. package/dist/clients/redact/secrets.js +0 -449
  557. package/dist/clients/resource-sampler.js +0 -628
  558. package/dist/clients/reverse-deps.js +0 -270
  559. package/dist/clients/review-graph/builder.js +0 -5118
  560. package/dist/clients/review-graph/format.js +0 -33
  561. package/dist/clients/review-graph/git-identity.js +0 -157
  562. package/dist/clients/review-graph/import-resolvers.js +0 -481
  563. package/dist/clients/review-graph/persist-worker.js +0 -10
  564. package/dist/clients/review-graph/query.js +0 -244
  565. package/dist/clients/review-graph/revision-drift.js +0 -21
  566. package/dist/clients/review-graph/service.js +0 -96
  567. package/dist/clients/review-graph/shared-extraction-ir.js +0 -82
  568. package/dist/clients/review-graph/symbol-id.js +0 -103
  569. package/dist/clients/review-graph/tsconfig-paths.js +0 -299
  570. package/dist/clients/review-graph/types.js +0 -1
  571. package/dist/clients/review-graph/workspace-modules.js +0 -449
  572. package/dist/clients/review-graph-logger.js +0 -81
  573. package/dist/clients/ruff-client.js +0 -152
  574. package/dist/clients/rules-scanner.js +0 -131
  575. package/dist/clients/run-duration.js +0 -55
  576. package/dist/clients/runtime-agent-end.js +0 -746
  577. package/dist/clients/runtime-config.js +0 -77
  578. package/dist/clients/runtime-context.js +0 -211
  579. package/dist/clients/runtime-coordinator.js +0 -1290
  580. package/dist/clients/runtime-session.js +0 -2485
  581. package/dist/clients/runtime-tool-call.js +0 -1284
  582. package/dist/clients/runtime-tool-result.js +0 -2034
  583. package/dist/clients/runtime-turn.js +0 -3600
  584. package/dist/clients/rust-client.js +0 -97
  585. package/dist/clients/safe-spawn.js +0 -2166
  586. package/dist/clients/sanitize.js +0 -286
  587. package/dist/clients/scan-utils.js +0 -25
  588. package/dist/clients/scratch-tree-policy.js +0 -165
  589. package/dist/clients/search-read-registration.js +0 -94
  590. package/dist/clients/secret-findings.js +0 -114
  591. package/dist/clients/security-scan-client.js +0 -352
  592. package/dist/clients/session-event-guard.js +0 -297
  593. package/dist/clients/session-lifecycle.js +0 -484
  594. package/dist/clients/session-start-observability.js +0 -94
  595. package/dist/clients/session-state-store.js +0 -101
  596. package/dist/clients/session-summary.js +0 -15
  597. package/dist/clients/sessionstart-logger.js +0 -27
  598. package/dist/clients/sg-runner.js +0 -876
  599. package/dist/clients/sgconfig.js +0 -347
  600. package/dist/clients/shared-checkout-guard.js +0 -338
  601. package/dist/clients/single-flight.js +0 -158
  602. package/dist/clients/situational-tool-telemetry.js +0 -108
  603. package/dist/clients/skills-resolver.js +0 -141
  604. package/dist/clients/slow-fs.js +0 -138
  605. package/dist/clients/smells-rollup.js +0 -231
  606. package/dist/clients/source-filter.js +0 -596
  607. package/dist/clients/source-walker.js +0 -211
  608. package/dist/clients/spawn-output-cap.js +0 -37
  609. package/dist/clients/spawn-timeout-cooldown.js +0 -110
  610. package/dist/clients/stale-marker.js +0 -17
  611. package/dist/clients/startup-scan.js +0 -400
  612. package/dist/clients/startup-timing.js +0 -130
  613. package/dist/clients/string-utils.js +0 -33
  614. package/dist/clients/subagent-mode.js +0 -111
  615. package/dist/clients/symbol-containment.js +0 -24
  616. package/dist/clients/symbol-types.js +0 -5
  617. package/dist/clients/test-runner-client.js +0 -2507
  618. package/dist/clients/test-runner-delivery.js +0 -287
  619. package/dist/clients/todo-scanner.js +0 -229
  620. package/dist/clients/tool-agreement.js +0 -423
  621. package/dist/clients/tool-config.js +0 -249
  622. package/dist/clients/tool-cwd.js +0 -270
  623. package/dist/clients/tool-definition.js +0 -47
  624. package/dist/clients/tool-event.js +0 -105
  625. package/dist/clients/tool-policy.js +0 -2755
  626. package/dist/clients/tool-probe.js +0 -53
  627. package/dist/clients/tool-render.js +0 -156
  628. package/dist/clients/tool-set-policy.js +0 -118
  629. package/dist/clients/tree-sitter-cache.js +0 -364
  630. package/dist/clients/tree-sitter-client.js +0 -4134
  631. package/dist/clients/tree-sitter-logger.js +0 -100
  632. package/dist/clients/tree-sitter-navigator.js +0 -269
  633. package/dist/clients/tree-sitter-query-loader.js +0 -569
  634. package/dist/clients/tree-sitter-shared.js +0 -158
  635. package/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
  636. package/dist/clients/trivy-client.js +0 -467
  637. package/dist/clients/tui-fit.js +0 -50
  638. package/dist/clients/turn-context.js +0 -52
  639. package/dist/clients/turn-summary-render.js +0 -72
  640. package/dist/clients/turn-summary.js +0 -132
  641. package/dist/clients/typos-config.js +0 -29
  642. package/dist/clients/user-notify.js +0 -51
  643. package/dist/clients/vanished-instance-marker.js +0 -57
  644. package/dist/clients/warm-attach.js +0 -246
  645. package/dist/clients/widget-state.js +0 -1677
  646. package/dist/clients/word-index-logger.js +0 -49
  647. package/dist/clients/word-index-store.js +0 -459
  648. package/dist/clients/word-index.js +0 -2080
  649. package/dist/clients/workspace-topology.js +0 -389
  650. package/dist/clients/write-ordering-guard.js +0 -65
  651. package/dist/clients/zizmor-config.js +0 -327
  652. package/dist/i18n.js +0 -69
  653. package/dist/mcp/build-staleness.js +0 -124
  654. package/dist/scripts/lib/process-scan.mjs +0 -583
  655. package/dist/scripts/lib/skills-predicate.mjs +0 -129
  656. package/dist/tools/activate-tools.js +0 -87
  657. package/dist/tools/ast-grep-outline.js +0 -137
  658. package/dist/tools/ast-grep-replace.js +0 -244
  659. package/dist/tools/ast-grep-search.js +0 -762
  660. package/dist/tools/effective-config.js +0 -90
  661. package/dist/tools/lens-diagnostic-mark.js +0 -375
  662. package/dist/tools/lens-diagnostics.js +0 -2544
  663. package/dist/tools/lsp-diagnostics.js +0 -1634
  664. package/dist/tools/lsp-navigation.js +0 -1367
  665. package/dist/tools/lsp-structured-output.js +0 -294
  666. package/dist/tools/module-report.js +0 -382
  667. package/dist/tools/project-report.js +0 -86
  668. package/dist/tools/render-compact.js +0 -374
  669. package/dist/tools/scan-progress.js +0 -53
  670. package/dist/tools/shared.js +0 -29
  671. package/dist/tools/symbol-search.js +0 -155
@@ -1,4465 +0,0 @@
1
- /**
2
- * LSP Client for pi-lens
3
- *
4
- * Handles JSON-RPC communication with language servers:
5
- * - Initialize/shutdown lifecycle
6
- * - Document synchronization (didOpen, didChange)
7
- * - Diagnostics with debouncing
8
- * - Request/response handling
9
- */
10
- import { spawn as nodeSpawn } from "node:child_process";
11
- import { EventEmitter } from "node:events";
12
- import { access, lstat, readFile } from "node:fs/promises";
13
- import * as os from "node:os";
14
- import { pathToFileURL } from "node:url";
15
- import { emitBounded } from "../bounded-telemetry.js";
16
- import { withTimeout } from "../deadline-utils.js";
17
- import { minimatch } from "../deps/minimatch.js";
18
- import { mapWithConcurrency } from "../map-with-concurrency.js";
19
- import { incrementDegradationCount, recordDegradationOnce, } from "../degradation-ledger.js";
20
- // vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
21
- // `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
22
- import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
23
- import { logExtension } from "../extension-log.js";
24
- import { recordLspChild, removeLspChild } from "../instance-registry.js";
25
- import { workspaceDiagnosticsCacheSessionStart } from "./workspace-diagnostics-session.js";
26
- import { logLatency } from "../latency-logger.js";
27
- import { newLspMutationCorrelationId, } from "../lsp-mutation.js";
28
- import { getProcessSingleton } from "../process-singletons.js";
29
- import { getAmbientAbortSignal, isOwnLiveChild, releaseOwnChildPid, } from "../safe-spawn.js";
30
- import { raceToCompletion } from "./aggregation.js";
31
- import { hashDiagnosticContent, } from "./diagnostic-binding.js";
32
- import { applyWorkspaceEdit, normalizeWorkspaceEditToUtf16 } from "./edits.js";
33
- import { normalizeMapKey, uriToPath } from "./path-utils.js";
34
- import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
35
- import { negotiateSyncKind, TEXT_DOCUMENT_SYNC_KIND_FULL, TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL, } from "./sync-kind.js";
36
- import { probeTsserverProjectIdentity } from "./tsserver-sync.js";
37
- import { getStrategy } from "./wait-policy/index.js";
38
- import { WatchedFilesQueue } from "./watch-queue.js";
39
- import { clearAllWorkspaceDiagnosticsCaches, clearWorkspaceDiagnosticsCacheAtAndAbove, } from "./workspace-diagnostics-cache.js";
40
- // Opt-in publishDiagnostics trace (PILENS_PUB_DEBUG=1) — read once, negligible
41
- // hot-path cost. Surfaces each server's publish behavior (version + count) to
42
- // diagnose the clean-file affirmative-signal question (#240): which servers
43
- // publish an empty-with-version set on a clean scan vs go silent.
44
- const PUB_DEBUG = Boolean(process.env.PILENS_PUB_DEBUG);
45
- /**
46
- * #472/#449: extract a per-spawn-unique "marker" from an LSP server's resolved
47
- * args, for the instance registry's command-line re-identification fallback
48
- * (used when a recorded child's pid is dead/recycled but its process tree
49
- * grandchild — e.g. ast-grep's native exe behind a dead node wrapper — is
50
- * still alive under a different pid).
51
- *
52
- * Generalized, NOT ast-grep-specific (uniformity requirement — no per-server
53
- * special casing): the value immediately following a `--config`/`-c` flag, if
54
- * that value looks like a path under a temp directory (`os.tmpdir()`). This
55
- * covers ast-grep's `lsp --config <tmp sgconfig path>` (clients/sgconfig.ts)
56
- * today, and any other server later launched with a temp-file `--config`/`-c`
57
- * argument, without new server-specific code.
58
- */
59
- function extractSpawnMarker(args) {
60
- if (!args)
61
- return undefined;
62
- const tmpDir = os.tmpdir();
63
- for (let i = 0; i < args.length - 1; i++) {
64
- const flag = args[i];
65
- if (flag === "--config" || flag === "-c") {
66
- const value = args[i + 1];
67
- if (value?.startsWith(tmpDir))
68
- return value;
69
- }
70
- }
71
- return undefined;
72
- }
73
- // --- Constants ---
74
- export const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
75
- /**
76
- * The client capabilities advertised in every `initialize`. The textDocument set
77
- * is intentionally COMPLETE and spec-compliant: servers built on
78
- * OmniSharp.Extensions.LanguageServer (PowerShell Editor Services, #278)
79
- * dereference these sub-capabilities while handling `initialize` and throw a
80
- * NullReferenceException when an expected one is absent, hanging the handshake. A
81
- * partial textDocument object (the old `synchronization: {didOpen, didChange}` —
82
- * not even valid TextDocumentSyncClientCapabilities fields) triggered exactly
83
- * that. Declaring the full set is harmless to other servers (they act only on the
84
- * requests we actually send), so this is the single, server-agnostic shape.
85
- * Exported for the regression guard in client-internals tests.
86
- */
87
- export const CLIENT_CAPABILITIES = {
88
- general: { positionEncodings: ADVERTISED_POSITION_ENCODINGS },
89
- // #974: workDoneProgress is intentionally NOT advertised. pi-lens never
90
- // consumes `$/progress` notifications (grepped: zero listeners anywhere in
91
- // clients/), so declaring the capability only invites servers to open
92
- // progress tokens pi-lens will silently ignore — and opengrep's
93
- // `--experimental` LSP mode crash-loops when it can't parse our
94
- // spec-correct `{"result": null}` reply to its
95
- // `window/workDoneProgress/create` request. "Only advertise what you
96
- // implement" — the `window/workDoneProgress/create` handler below stays as
97
- // a defensive no-op in case a server ignores capabilities and asks anyway.
98
- window: {},
99
- workspace: {
100
- workspaceFolders: true,
101
- configuration: true,
102
- didChangeWatchedFiles: { dynamicRegistration: true },
103
- fileOperations: {
104
- dynamicRegistration: false,
105
- willRename: true,
106
- didRename: true,
107
- },
108
- },
109
- textDocument: {
110
- synchronization: {
111
- dynamicRegistration: false,
112
- willSave: false,
113
- willSaveWaitUntil: false,
114
- didSave: true,
115
- },
116
- completion: {
117
- dynamicRegistration: false,
118
- completionItem: { snippetSupport: false },
119
- },
120
- hover: { dynamicRegistration: false },
121
- signatureHelp: { dynamicRegistration: false },
122
- definition: { dynamicRegistration: false },
123
- typeDefinition: { dynamicRegistration: false },
124
- implementation: { dynamicRegistration: false },
125
- references: { dynamicRegistration: false },
126
- documentSymbol: { dynamicRegistration: false },
127
- codeAction: {
128
- dynamicRegistration: false,
129
- dataSupport: true,
130
- resolveSupport: { properties: ["edit", "command"] },
131
- // #1971 review: without literal support, LSP 3.17 constrains
132
- // textDocument/codeAction responses to Command[] — resolve of an edit
133
- // would then be protocol-invalid. We parse CodeAction objects.
134
- codeActionLiteralSupport: {
135
- codeActionKind: {
136
- valueSet: [
137
- "quickfix",
138
- "refactor",
139
- "refactor.extract",
140
- "refactor.inline",
141
- "refactor.rewrite",
142
- "source",
143
- "source.organizeImports",
144
- ],
145
- },
146
- },
147
- },
148
- rename: { dynamicRegistration: false },
149
- publishDiagnostics: {
150
- relatedInformation: true,
151
- versionSupport: true,
152
- },
153
- },
154
- };
155
- const NAV_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_NAV_REQUEST_TIMEOUT_MS", 10_000); // 10s — per-request ceiling; prevents heavy servers (vue, svelte) from hanging
156
- const DIAGNOSTICS_WAIT_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_DIAGNOSTICS_WAIT_MS", 10_000);
157
- const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_RETRY_INTERVAL_MS", 250);
158
- // Per-request ceiling for pull diagnostics (textDocument/diagnostic), mirroring
159
- // NAV_REQUEST_TIMEOUT_MS. safeSendRequest only settles on a reply or a *destroyed*
160
- // stream, so a pull-mode server that is alive but hung (accepts the request, never
161
- // replies) would await forever — hanging clientWaitForDiagnostics and, upstream,
162
- // the diagnostics flush. On timeout the request is treated as `unavailable`, which
163
- // (per #240) is NOT read as clean and falls through to the bounded push backstop.
164
- const PULL_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS", 10_000);
165
- // #1773: the smallest budget this codebase treats as usable — dispatching
166
- // BELOW it is not a real attempt. The old clamp (`Math.max(1, ...)`) let an
167
- // exhausted `budgetMs` (0 or negative) through as a 1ms pull — sent, timed
168
- // out by construction, and recorded as a genuine `lsp_pull_diagnostic_timeout`
169
- // with a fabricated `effectiveBudgetMs: 1` (observed live: both pull-timeout
170
- // records in the 2026-08-20 plegma dogfood session carried exactly that).
171
- // 5ms sits below the smallest budget this codebase's own regression fixtures
172
- // already treat as a real dispatch (`tests/clients/lsp/pull-diagnostic-
173
- // timeout-telemetry.test.ts` exercises 20ms and 30ms as genuinely-attempted-
174
- // then-timed-out) and above the 1-4ms band that is indistinguishable from
175
- // "already exhausted" — a local stdio server round trip (write + compute +
176
- // parse) needs more than a handful of milliseconds even on the fast path.
177
- // `budgetMs === PULL_MIN_USABLE_BUDGET_MS` dispatches (the comparison below
178
- // is strict `<`), so the name reads literally: this is the floor value that
179
- // still gets a real attempt.
180
- const PULL_MIN_USABLE_BUDGET_MS = 5;
181
- const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS", 1000);
182
- // #1620: the `exit` NOTIFICATION needs its own ceiling, for the same reason the
183
- // `shutdown` REQUEST has one. A notification write on a pipe that is not
184
- // draining neither resolves nor rejects, so `safeSendNotification`'s catch never
185
- // runs and the await is unbounded. The #1459 wedged-write breaker calls this
186
- // teardown precisely when that stdin has already been PROVEN wedged, so the
187
- // unbounded await was the whole mechanism of the leak. Kept equal to the request
188
- // budget: a healthy server still gets a brief graceful window before the kill.
189
- const EXIT_NOTIFY_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXIT_NOTIFY_TIMEOUT_MS", 1000);
190
- // #1277: cheap liveness round-trip for the silent-clean gates (`index.ts`).
191
- // Those gates convert a diagnostics-wait timeout into a confirmed-clean
192
- // result from a STATIC capability classification (`silentOnClean`) alone —
193
- // but a wedged server (accepted the notify write, then hung) satisfies that
194
- // classification identically to a genuinely clean one. This is deliberately
195
- // short relative to NAV_REQUEST_TIMEOUT_MS: it only needs to prove the
196
- // connection round-trips SOMETHING before the touch reports clean, not
197
- // complete a real navigation request.
198
- const LIVENESS_PING_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_LIVENESS_PING_TIMEOUT_MS", 300);
199
- // Distinctive, unlikely-to-collide query string — the response content is
200
- // never inspected, only whether one arrived before the timeout.
201
- const LIVENESS_PING_QUERY = "__pi_lens_liveness_ping__";
202
- // #1104: bound on `state.workspacePullResultCache` — one entry per distinct
203
- // file the server has ever returned a `resultId` for across this client's
204
- // lifetime. A full clear on overflow (rather than an LRU) is fine, same
205
- // reasoning as `DISK_BINDING_MEMO_MAX` in diagnostic-binding.ts: each entry is
206
- // cheaply rebuilt by the next full pull, the worst case is just one extra full
207
- // (non-`unchanged`) report per affected file.
208
- const WORKSPACE_PULL_RESULT_CACHE_MAX = 4096;
209
- // #1713: `workspace/diagnostic` has no per-file/per-identifier request to key
210
- // telemetry identity on — it is one project-wide pull — so timeout/late-answer
211
- // records use this fixed subject/scope instead of a real path.
212
- const WORKSPACE_PULL_SCOPE = "*workspace*";
213
- // #1669 review N2: cap on simultaneous re-pulls the `workspace/diagnostic/
214
- // refresh` handler fires for open documents. A workspace with hundreds of
215
- // open documents fanning out one `textDocument/diagnostic` request each,
216
- // with no cap, floods the server the refresh itself just told us is under
217
- // load. Small and fixed — this is a background improvement after the
218
- // protocol reply, never something worth tuning per project.
219
- const REFRESH_REPULL_CONCURRENCY = 4;
220
- // Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
221
- // (30s): the command is mutating and legitimately long-running (a real server
222
- // refactor / organize-imports), so this must not truncate valid work — it only
223
- // stops a hung server from blocking the caller forever. On timeout the command
224
- // may still be applying server-side; we surface that rather than pretend it ran.
225
- const EXECUTE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS", 30_000);
226
- // #1412 H1: short ceiling for the read-only tsserver project-identity probe.
227
- // This is a telemetry sample, not a mutation — it must never hold the door
228
- // open for anything close to EXECUTE_COMMAND_TIMEOUT_MS.
229
- const PROBE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PROJECT_IDENTITY_PROBE_TIMEOUT_MS", 2_500);
230
- const LSP_CRASH_CODES = new Set([
231
- "ERR_STREAM_DESTROYED",
232
- "ERR_STREAM_WRITE_AFTER_END",
233
- "EPIPE",
234
- "ECONNRESET",
235
- ]);
236
- let crashGuardInstalled = false;
237
- function isIgnorableLspRuntimeCrash(err) {
238
- if (!(err instanceof Error))
239
- return false;
240
- const code = err.code;
241
- if (code && LSP_CRASH_CODES.has(code))
242
- return true;
243
- const msg = err.message.toLowerCase();
244
- const stack = (err.stack ?? "").toLowerCase();
245
- return (msg.includes("stream") ||
246
- msg.includes("write after end") ||
247
- stack.includes("vscode-jsonrpc/lib/node/ril.js"));
248
- }
249
- function installCrashGuard() {
250
- if (crashGuardInstalled)
251
- return;
252
- crashGuardInstalled = true;
253
- process.on("uncaughtException", (err) => {
254
- if (isIgnorableLspRuntimeCrash(err)) {
255
- return;
256
- }
257
- throw err;
258
- });
259
- process.on("unhandledRejection", (reason) => {
260
- if (isIgnorableLspRuntimeCrash(reason)) {
261
- return;
262
- }
263
- throw reason instanceof Error ? reason : new Error(String(reason));
264
- });
265
- }
266
- /** #2065: bound both the path count and UTF-16 heap represented by retained
267
- * Incremental-sync text. The map keeps the newest sent paths when evicting. */
268
- export const MAX_INCREMENTAL_TEXT_RETAINED_ENTRIES = 128;
269
- export const MAX_INCREMENTAL_TEXT_RETAINED_BYTES = 64 * 1024 * 1024;
270
- /**
271
- * #2065 fix round 1: a PROJECTION registry, not a second source of truth for
272
- * "which clients are live" — its only job is letting `memory-sampler.ts`
273
- * total retained-text bytes across clients without importing `LSPService`.
274
- * Considered projecting straight off `LSPService.state.clients` (the
275
- * manager's own client map, `clients/lsp/index.ts`) instead of keeping this
276
- * Set at all, and rejected it for two reasons:
277
- * 1. Import direction: `clients/lsp/index.ts` imports THIS file, not the
278
- * reverse, so reading `LSPService.state.clients` from here (or from
279
- * `memory-sampler.ts`, which every other snapshot in that module reaches
280
- * by importing the owning subsystem directly, e.g.
281
- * `getReviewGraphWorkspaceCacheSnapshot`, `getDispatchCascadeCacheStats`)
282
- * means importing the higher-level manager from the lower-level client
283
- * module — a layering inversion, not a cycle fix.
284
- * 2. `LSPService.state.clients` is ITSELF only lazily reconciled against
285
- * real client death — a crashed client's slot is noticed and evicted on
286
- * the next `getClientForFile` attach, not at crash time (see
287
- * `markExitedIfUnset`'s docstring above). Projecting off it would not
288
- * give memory-sampler any more real-time accuracy than this Set; it
289
- * would just move the same "who deregisters, and when" question onto a
290
- * larger, higher-risk surface (the manager's routing map) for no gain.
291
- *
292
- * What makes this safe to keep as a second collection: it has exactly ONE
293
- * lifecycle seam. `spawnClient` is the only writer that adds (below), and
294
- * `disposeClientConnection` is the only writer that removes — and that
295
- * function is itself the single convergence point every permanent-death path
296
- * (`connection.onError`, `connection.onClose`, the process `exit` handler,
297
- * and `clientShutdownOnce`'s `finally`) already funnels through, guarded by
298
- * the same `connectionDisposed` idempotency flag that gates the connection
299
- * teardown itself. Membership can therefore never drift from "has this
300
- * client's connection been torn down" — there is nowhere else in the file
301
- * that can flip a client from alive to permanently dead without going
302
- * through `disposeClientConnection`.
303
- *
304
- * PROCESS-SCOPED, not module-scoped (#2130 criterion 2). This Set is what
305
- * `memory_sample.subsystems.lsp.clients` counts, and a module-scope Set counted
306
- * only the clients this MODULE EVALUATION spawned. pi evaluates the pi-lens
307
- * graph up to nine times per process (#2146, measured `host_boot` = 9) and each
308
- * evaluation builds its own `LSPService` and fleet (#2157 item 1), so a
309
- * secondary root's servers — spawned by a later evaluation — were invisible to
310
- * the sampler running in the first. That is the reported symptom exactly:
311
- * `clients: 1` in `memory_sample` beside `lspChildCount: 2` in `instances.json`,
312
- * with two tsservers alive. Sharing the Set through `getProcessSingleton` makes
313
- * both the count and `roots` span every root the process serves.
314
- *
315
- * Both seams share it, which is the whole requirement: sharing only the add
316
- * site would build a Set that never shrinks, a worse leak than the undercount.
317
- */
318
- const ACTIVE_CLIENTS_FAMILY = "lsp-client.active-clients";
319
- /** Bump when the cell's shape changes. */
320
- const ACTIVE_CLIENTS_VERSION = 1;
321
- function activeLspClientSet() {
322
- return getProcessSingleton(ACTIVE_CLIENTS_FAMILY, ACTIVE_CLIENTS_VERSION, () => new Set());
323
- }
324
- export function getLspDocumentTextRetentionSnapshot() {
325
- let entries = 0;
326
- let bytes = 0;
327
- const roots = new Set();
328
- const active = activeLspClientSet();
329
- for (const state of active) {
330
- entries += state.incrementalTextRetainedEntries ?? 0;
331
- bytes += state.incrementalTextRetainedBytes ?? 0;
332
- if (typeof state.root === "string" && state.root.length > 0) {
333
- roots.add(state.root);
334
- }
335
- }
336
- return { clients: active.size, entries, bytes, roots: roots.size };
337
- }
338
- function isClientAlive(state) {
339
- return (state.isConnected && !state.isDestroyed && !state.lspProcess.process.killed);
340
- }
341
- function disposeClientConnection(state) {
342
- if (state.connectionDisposed)
343
- return;
344
- state.connectionDisposed = true;
345
- // #2065 fix round 1: this is the ONE place every permanent-death path
346
- // converges (onError, onClose, the process 'exit' handler, and
347
- // clientShutdownOnce's finally) — see setupConnectionLifecycle below and
348
- // clientShutdownOnce. Deregistering here, guarded by the idempotency flag
349
- // above, means a crash that never re-attaches still frees its retained
350
- // text instead of pinning it for the rest of the process lifetime.
351
- activeLspClientSet().delete(state);
352
- try {
353
- state.connection.dispose();
354
- }
355
- catch {
356
- // ignore
357
- }
358
- }
359
- export async function killProcessTree(proc, pid, options = {}) {
360
- // If our child has already exited, its PID is dead and the OS may have
361
- // RECYCLED it. The Windows `taskkill /F /T` below force-kills the PID's whole
362
- // tree, so on a recycled PID it would kill an unrelated process (in the test
363
- // suite this occasionally nuked a vitest worker fork → "Worker exited
364
- // unexpectedly" with no fatal dump). There is nothing left for us to kill, and
365
- // the handle-based proc.kill() below is moot, so return early.
366
- if ((proc.exitCode != null || proc.signalCode != null) &&
367
- !options.processExiting) {
368
- // #3091 F1-r2b: this pid will never be signalled again, so retire the
369
- // ownership hold taken at spawn rather than leaving it to age out.
370
- releaseOwnChildPid(pid);
371
- proc.unref?.();
372
- return;
373
- }
374
- if (process.platform === "win32" && pid > 0) {
375
- // Host process is exiting (loop already closing): never spawn a child here —
376
- // the spawn's uv_async_send on the closing loop-wakeup handle hard-aborts
377
- // (src\win\async.c). Kill the direct child via the handle we already hold
378
- // (TerminateProcess; synchronous, no async handle).
379
- //
380
- // #472 CORRECTION of a prior false claim here ("orphaned grandchildren are
381
- // reaped by the OS as the host exits"): Windows does NOT kill children when
382
- // a parent dies. For shell/.cmd-wrapped servers the direct child is
383
- // cmd.exe, so this path only ever kills the wrapper — the actual server
384
- // (its grandchild) survives by design whenever it doesn't independently
385
- // exit. It relies entirely on best-effort backstops instead: (1) the
386
- // server observing stdin EOF once the wrapper's pipes close, (2) LSP
387
- // `initialize.processId: process.pid` (some servers self-watchdog on that
388
- // pid dying — typescript-language-server does, ast-grep's native binary
389
- // does not, an upstream spec violation), and (3) the #449/#472
390
- // cross-process instance registry's orphan reaper, which is the only
391
- // mechanism that works regardless of why a pipe write-end stayed open
392
- // (e.g. Windows handle-inheritance capture by a long-lived process). This
393
- // is why registering every LSP child at spawn matters uniformly — do NOT
394
- // weaken this direct-child-only kill to try to chase grandchildren here;
395
- // spawning taskkill in this branch is exactly the libuv hazard above.
396
- if (options.processExiting) {
397
- try {
398
- proc.kill();
399
- }
400
- catch {
401
- // best-effort
402
- }
403
- proc.unref?.();
404
- return;
405
- }
406
- try {
407
- // Absolute path avoids PATH-resolution: SystemRoot is set by Windows itself.
408
- const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
409
- const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
410
- shell: false,
411
- windowsHide: true,
412
- stdio: "ignore",
413
- detached: !!options.fast,
414
- });
415
- if (options.fast) {
416
- killer.unref();
417
- proc.unref?.();
418
- return;
419
- }
420
- await new Promise((resolve) => {
421
- killer.once("close", () => resolve());
422
- killer.once("error", (err) => {
423
- logLatency({
424
- type: "phase",
425
- phase: "lsp_kill_escalation",
426
- filePath: "",
427
- durationMs: 0,
428
- metadata: { pid, platform: "win32", taskkillError: String(err) },
429
- });
430
- resolve();
431
- });
432
- });
433
- }
434
- catch (err) {
435
- logLatency({
436
- type: "phase",
437
- phase: "lsp_kill_escalation",
438
- filePath: "",
439
- durationMs: 0,
440
- metadata: { pid, platform: "win32", taskkillSpawnError: String(err) },
441
- });
442
- }
443
- return;
444
- }
445
- const killPosixProcessGroup = (signal) => {
446
- // #2042: one ownership predicate for every kill-by-raw-pid, replacing
447
- // the `pid <= 0` sign check that let a test double's invented pid
448
- // through. A pid we do not own falls back to `killDirectChild` below,
449
- // which signals through the retained handle and can only ever reach
450
- // our own child.
451
- //
452
- // #3091 F1: `proc` is deliberately NOT passed. The handle arm means
453
- // "already exited ⇒ refuse", and this group kill must still fire when
454
- // the direct child is dead — the early return at :1269 is skipped under
455
- // `options.processExiting`, and a POSIX group outlives its leader, so
456
- // the group signal is the only thing that reaps surviving grandchildren
457
- // at host exit (#2026). Ownership here comes from the kernel and, once
458
- // the leader is gone, from the verdict recorded while it was alive.
459
- if (!isOwnLiveChild(pid, "lsp-stop-posix-group"))
460
- return false;
461
- try {
462
- process.kill(-pid, signal);
463
- return true;
464
- }
465
- catch {
466
- return false;
467
- }
468
- };
469
- const killDirectChild = (signal) => {
470
- try {
471
- proc.kill(signal);
472
- }
473
- catch {
474
- // best-effort
475
- }
476
- };
477
- try {
478
- // #1114: gate the escalation on OBSERVED exit, not `proc.killed`. Node
479
- // only sets `proc.killed = true` when `proc.kill()` (the ChildProcess
480
- // method) successfully SENDS a signal — never when the process actually
481
- // dies, and the primary SIGTERM path above goes through the raw
482
- // `process.kill(-pid, …)` process-group call, which never touches
483
- // `proc.killed` at all. Checking `!proc.killed` here was therefore
484
- // either always-true (unconditional SIGKILL after the window,
485
- // regardless of whether the group already died — group-kill path) or
486
- // always-false/dead (direct-child fallback path, same shape as the
487
- // safe-spawn escalation bug). An `exit` listener set once, up front,
488
- // gives a real observed-death signal for both. Seeded from the same
489
- // `exitCode`/`signalCode` pre-check the top-of-function early return
490
- // uses (:689) — that early return is skipped when
491
- // `options.processExiting` is set, so a process that was ALREADY dead
492
- // on entry can still reach here; without seeding, `exited` would stay
493
- // false (the "exit" event already fired before this listener was
494
- // attached) and the fast/non-fast branches below would still fire a
495
- // redundant group SIGKILL at the escalation window.
496
- let exited = proc.exitCode != null || proc.signalCode != null;
497
- proc.once?.("exit", () => {
498
- exited = true;
499
- });
500
- if (!killPosixProcessGroup("SIGTERM")) {
501
- killDirectChild("SIGTERM");
502
- }
503
- if (options.fast) {
504
- const timer = setTimeout(() => {
505
- if (!exited) {
506
- logLatency({
507
- type: "phase",
508
- phase: "lsp_kill_escalation",
509
- filePath: "",
510
- durationMs: 1500,
511
- metadata: { pid, platform: "posix", method: "SIGKILL", fast: true },
512
- });
513
- if (!killPosixProcessGroup("SIGKILL")) {
514
- killDirectChild("SIGKILL");
515
- }
516
- }
517
- // AFTER the escalation, never before it: this tick issues the
518
- // last signal this pid can receive, and retiring the ownership
519
- // hold first would make that very SIGKILL the thing that gets
520
- // refused (#3091 F1-r2b).
521
- releaseOwnChildPid(pid);
522
- }, 1500);
523
- timer.unref?.();
524
- proc.unref?.();
525
- return;
526
- }
527
- // SIGTERM → exit-or-1.5s → SIGKILL escalation. SIGTERM alone can leave
528
- // zombie processes if the server hangs — but a server that dies promptly
529
- // must resolve on its exit event, not sleep the full escalation window
530
- // (that unconditional 1500ms was the whole cost of every graceful LSP
531
- // teardown, ×N clients per session and per test).
532
- const exitedInTime = await new Promise((resolve) => {
533
- if (proc.exitCode != null || proc.signalCode != null) {
534
- resolve(true);
535
- return;
536
- }
537
- const onExit = () => {
538
- clearTimeout(timer);
539
- resolve(true);
540
- };
541
- const timer = setTimeout(() => {
542
- proc.off?.("exit", onExit);
543
- resolve(false);
544
- }, 1500);
545
- proc.once?.("exit", onExit);
546
- });
547
- if (!exitedInTime && !exited) {
548
- logLatency({
549
- type: "phase",
550
- phase: "lsp_kill_escalation",
551
- filePath: "",
552
- durationMs: 1500,
553
- metadata: { pid, platform: "posix", method: "SIGKILL", fast: false },
554
- });
555
- if (!killPosixProcessGroup("SIGKILL")) {
556
- killDirectChild("SIGKILL");
557
- }
558
- }
559
- releaseOwnChildPid(pid);
560
- }
561
- catch {
562
- // ignore
563
- }
564
- }
565
- export function stripDiagnosticNoiseLines(message) {
566
- const cleaned = message
567
- .split(/\r?\n/)
568
- .filter((line) => {
569
- const trimmed = line.trim();
570
- if (/^for further information visit\b/i.test(trimmed))
571
- return false;
572
- if (/^https?:\/\/\S+$/i.test(trimmed))
573
- return false;
574
- return true;
575
- })
576
- .join("\n")
577
- .trim();
578
- return cleaned || message.trim() || message;
579
- }
580
- function normalizeLspDiagnostic(diagnostic) {
581
- const message = stripDiagnosticNoiseLines(diagnostic.message);
582
- return message === diagnostic.message
583
- ? diagnostic
584
- : { ...diagnostic, message };
585
- }
586
- function normalizeLspDiagnostics(diagnostics) {
587
- return diagnostics.map(normalizeLspDiagnostic);
588
- }
589
- /**
590
- * Union of diagnostic lists, first occurrence wins, exact duplicates dropped.
591
- * Variadic since #1667 so the per-identifier pull sources merge through the
592
- * SAME key as the push/pull merge rather than growing a second dedupe rule.
593
- */
594
- function mergeDiagnosticLists(...lists) {
595
- const merged = [];
596
- const seen = new Set();
597
- for (const diagnostic of lists.flatMap((list) => list ?? [])) {
598
- const key = [
599
- diagnostic.range.start.line,
600
- diagnostic.range.start.character,
601
- diagnostic.range.end.line,
602
- diagnostic.range.end.character,
603
- diagnostic.code ?? "",
604
- diagnostic.source ?? "",
605
- diagnostic.message,
606
- ].join(":");
607
- if (seen.has(key))
608
- continue;
609
- seen.add(key);
610
- merged.push(diagnostic);
611
- }
612
- return merged;
613
- }
614
- function getMergedDiagnosticsForPath(state, normalizedPath) {
615
- // SAFETY: `diagnostics` is the pre-split field name that older states (and
616
- // older embedders holding a state built before `pushDiagnostics` existed)
617
- // still carry. It is absent from `LSPClientState` on purpose — new code
618
- // must not write it. The cast declares it OPTIONAL, so the read below is
619
- // still `undefined`-guarded and a state without it falls through to the
620
- // current field.
621
- const legacy = state;
622
- return mergeDiagnosticLists(state.pushDiagnostics?.get(normalizedPath) ??
623
- legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath));
624
- }
625
- /**
626
- * #1667: per-source pull-diagnostic bookkeeping.
627
- *
628
- * A pull-mode server can expose several diagnostic SOURCES, each named by its
629
- * registration's `registerOptions.identifier` (Roslyn: syntax, semantic,
630
- * analyzers). Each source answers independently, with its own `resultId` and
631
- * its own findings, so per-path state that used to be a single slot has to
632
- * become one slot per (path, source):
633
- *
634
- * - `pullResultIds` keys on `pullSourceKey(path, identifier)`, so an
635
- * `unchanged` report is only ever answered against the id THAT source
636
- * issued (#240/#1104 machinery, now per source).
637
- * - `documentPullDiagnosticsBySource` holds each source's findings, and
638
- * `documentPullDiagnostics` is their deduped union - recomputed whenever a
639
- * source reports, so a fresh answer from one source never wipes another's.
640
- *
641
- * The bare (identifier-less) source keys on the plain path, so a single-source
642
- * server stores exactly what it stored before this change.
643
- */
644
- // NUL is the one byte a filesystem path can never contain, so
645
- // `path + SEP + identifier` is unambiguous: no path/identifier pair can
646
- // produce the same key as a different pair, and the bare source's key (the
647
- // plain path) can never collide with a named source's.
648
- const PULL_SOURCE_KEY_SEPARATOR = "\u0000";
649
- const BARE_PULL_SOURCE = "";
650
- function pullSourceKey(normalizedPath, identifier) {
651
- return identifier === undefined
652
- ? normalizedPath
653
- : `${normalizedPath}${PULL_SOURCE_KEY_SEPARATOR}${identifier}`;
654
- }
655
- /** Every `pullResultIds` key belonging to a path - the bare key plus one per
656
- * identifier. Used by the clear path so a resync drops EVERY source's basis. */
657
- function pullSourceKeysForPath(state, normalizedPath) {
658
- const prefix = `${normalizedPath}${PULL_SOURCE_KEY_SEPARATOR}`;
659
- return [
660
- normalizedPath,
661
- ...[...(state.pullResultIds?.keys() ?? [])].filter((key) => key.startsWith(prefix)),
662
- ];
663
- }
664
- function pullSourcesFor(state, normalizedPath) {
665
- if (!state.documentPullDiagnosticsBySource) {
666
- state.documentPullDiagnosticsBySource = new Map();
667
- }
668
- let sources = state.documentPullDiagnosticsBySource.get(normalizedPath);
669
- if (!sources) {
670
- sources = new Map();
671
- state.documentPullDiagnosticsBySource.set(normalizedPath, sources);
672
- }
673
- return sources;
674
- }
675
- /** This source's currently stored findings for a path - what an `unchanged`
676
- * report inherits. Falls back to the whole per-path list when the source map
677
- * has no entry yet, so a state that pulled before this change (or a caller
678
- * that seeded `documentPullDiagnostics` directly) still inherits correctly. */
679
- function pullSourceDiagnostics(state, normalizedPath, identifier) {
680
- const sources = state.documentPullDiagnosticsBySource?.get(normalizedPath);
681
- const stored = sources?.get(identifier ?? BARE_PULL_SOURCE);
682
- if (stored)
683
- return stored;
684
- return sources && sources.size > 0
685
- ? []
686
- : (state.documentPullDiagnostics.get(normalizedPath) ?? []);
687
- }
688
- /** Store one source's findings and republish the union to
689
- * `documentPullDiagnostics`, which every reader outside this file uses. */
690
- function storePullSourceDiagnostics(state, normalizedPath, identifier, diagnostics) {
691
- const sources = pullSourcesFor(state, normalizedPath);
692
- sources.set(identifier ?? BARE_PULL_SOURCE, diagnostics);
693
- state.documentPullDiagnostics.set(normalizedPath, mergeDiagnosticLists(...sources.values()));
694
- }
695
- /** #1667: the pull generation for a path. The fan-out deliberately lets losing
696
- * pulls run to completion so their findings still reach the cache; a resync
697
- * that lands first must win, so every late write re-checks this. */
698
- function pullGenerationFor(state, normalizedPath) {
699
- return state.pullGenerations?.get(normalizedPath) ?? 0;
700
- }
701
- function bumpPullGeneration(state, normalizedPath) {
702
- if (!state.pullGenerations)
703
- state.pullGenerations = new Map();
704
- state.pullGenerations.set(normalizedPath, (state.pullGenerations.get(normalizedPath) ?? 0) + 1);
705
- }
706
- /** #1667: claim the newest-request slot for a source and return the claim.
707
- * Called at REQUEST time; `isNewestPullRequest` re-checks it at WRITE time so
708
- * a slow answer from an earlier fan-out cannot overwrite a newer one's. */
709
- function claimPullRequestSequence(state, sourceKey) {
710
- if (!state.pullRequestSequences)
711
- state.pullRequestSequences = new Map();
712
- const next = (state.pullRequestSequences.get(sourceKey) ?? 0) + 1;
713
- state.pullRequestSequences.set(sourceKey, next);
714
- return next;
715
- }
716
- function isNewestPullRequest(state, sourceKey, sequence) {
717
- return (state.pullRequestSequences?.get(sourceKey) ?? 0) === sequence;
718
- }
719
- /**
720
- * #1667: drop every trace of a diagnostic source the server just unregistered.
721
- *
722
- * `client/unregisterCapability` retires a source, but its findings sit in
723
- * `documentPullDiagnosticsBySource` and its resultId in `pullResultIds`, so
724
- * without this the union keeps serving a retired source's diagnostics until
725
- * something resyncs the file. Roslyn and vtsls re-register their sources on
726
- * solution reload, which is exactly when the old source's findings are stale.
727
- *
728
- * Purges the source's slice from every path and republishes each affected
729
- * path's union, so the removal is visible on the next read rather than only
730
- * after the next pull.
731
- */
732
- function retirePullSource(state, identifier) {
733
- const suffix = `${PULL_SOURCE_KEY_SEPARATOR}${identifier}`;
734
- for (const source of [state.pullResultIds, state.pullRequestSequences]) {
735
- if (!source)
736
- continue;
737
- for (const key of source.keys()) {
738
- if (key.endsWith(suffix))
739
- source.delete(key);
740
- }
741
- }
742
- for (const [normalizedPath, sources,] of state.documentPullDiagnosticsBySource ?? []) {
743
- if (!sources.delete(identifier))
744
- continue;
745
- state.documentPullDiagnostics.set(normalizedPath, mergeDiagnosticLists(...sources.values()));
746
- bumpDiagnosticsVersion(state, normalizedPath);
747
- state.diagnosticEmitter.emit("diagnostics", normalizedPath);
748
- }
749
- }
750
- /**
751
- * #1667: the diagnostic sources to pull for a document - always the bare
752
- * request (a server may answer it even while registering named sources, and it
753
- * is the ONLY request a single-source server understands), plus one per
754
- * distinct `identifier` on a live `textDocument/diagnostic` registration.
755
- *
756
- * Derived from `dynamicRegistrations` on every call rather than cached in a
757
- * parallel list: the registration map is the single source of truth, and
758
- * `client/unregisterCapability` must be able to retire a source immediately.
759
- */
760
- export function pullDiagnosticSources(state) {
761
- const identifiers = new Set();
762
- for (const registration of state.dynamicRegistrations?.values() ?? []) {
763
- if (registration.method !== "textDocument/diagnostic")
764
- continue;
765
- if (registration.identifier)
766
- identifiers.add(registration.identifier);
767
- }
768
- // Sorted for a stable, reproducible request order in logs and tests; the
769
- // fan-out is parallel, so order carries no priority.
770
- return [undefined, ...[...identifiers].sort((a, b) => a.localeCompare(b))];
771
- }
772
- /** #1531: bump the client-global diagnostics counter and stamp the path it was
773
- * bumped FOR. The single seam every "fresh diagnostics stored" site goes
774
- * through, so the per-path stamp can never drift from the global counter. */
775
- export function bumpDiagnosticsVersion(state, normalizedPath) {
776
- state.diagnosticsVersion += 1;
777
- state.diagnosticsVersionsByPath?.set(normalizedPath, state.diagnosticsVersion);
778
- }
779
- /** #1531: the global counter's value when diagnostics were last stored for
780
- * `normalizedPath`, or 0 when nothing is stored. 0 is a safe floor because the
781
- * counter starts at 0 and only ever increases, so any later publication for the
782
- * path compares greater than a baseline read while it was absent. */
783
- export function diagnosticsVersionForPath(state, normalizedPath) {
784
- return state.diagnosticsVersionsByPath?.get(normalizedPath) ?? 0;
785
- }
786
- /** Exported for tests: the quiet-window timer cancel on clear/resync is the
787
- * headline #1412 safety property (a stale versionless publication must never
788
- * land after the document content changed). */
789
- export function clearDiagnosticsForPath(state, normalizedPath) {
790
- // SAFETY: same pre-split field names as `getMergedDiagnosticsForPath`.
791
- // Clearing must cover the legacy maps too, or a state that still carries
792
- // them replays a stale diagnostic after the clear. Both are declared
793
- // OPTIONAL, so every use below is `?.`-guarded on a state that lacks them.
794
- const legacy = state;
795
- state.pushDiagnostics?.delete(normalizedPath);
796
- const pending = state.pendingDiagnostics?.get(normalizedPath);
797
- if (pending)
798
- clearTimeout(pending);
799
- state.pendingDiagnostics?.delete(normalizedPath);
800
- state.pushDiagnosticTimestamps?.delete(normalizedPath);
801
- state.documentPullDiagnostics?.delete(normalizedPath);
802
- state.documentPullDiagnosticTimestamps?.delete(normalizedPath);
803
- state.diagnosticDocVersions?.delete(normalizedPath);
804
- // #1095: a cleared path must never serve a stale content binding alongside a
805
- // later publish — drop it with the diagnostics it described. (The last-sent
806
- // `documentContentHashes` record is intentionally retained: it describes what
807
- // we sent, which the NEXT publish for that version still needs to bind to.)
808
- state.diagnosticBindings?.delete(normalizedPath);
809
- // #1531: the per-path publication stamp describes diagnostics that no longer
810
- // exist — drop it with them. Safe because the stamps hold the GLOBAL counter's
811
- // value: a baseline captured before this clear still compares less than any
812
- // later publication for the path, so dropping the entry cannot manufacture a
813
- // missed answer.
814
- state.diagnosticsVersionsByPath?.delete(normalizedPath);
815
- // #1104: a resync invalidates any `unchanged`-report basis too — the next
816
- // pull must not inherit a resultId/contentHash computed against the
817
- // content this resync just replaced.
818
- // #1667: every SOURCE's basis, not just the bare one - a multi-source server
819
- // holds one resultId per identifier, and leaving even one behind lets the
820
- // next pull inherit against content this resync replaced.
821
- for (const key of pullSourceKeysForPath(state, normalizedPath)) {
822
- state.pullResultIds?.delete(key);
823
- }
824
- // `pullRequestSequences` is deliberately NOT cleared here. It is a
825
- // monotonic per-source counter, and resetting it would let a request issued
826
- // BEFORE this resync claim the same number as one issued after, so the stale
827
- // answer would pass the newest-request check. The two guards cover different
828
- // cases and neither subsumes the other: the generation bump below invalidates
829
- // in-flight work when nothing newer was requested, and the sequence stamp
830
- // invalidates it when something newer was.
831
- state.documentPullDiagnosticsBySource?.delete(normalizedPath);
832
- // #1667: invalidate any pull still in flight for this path. The fan-out lets
833
- // losing pulls run to completion so their findings still reach the cache;
834
- // without this bump one of them could resurrect what this resync cleared.
835
- bumpPullGeneration(state, normalizedPath);
836
- state.workspacePullResultCache?.delete(normalizedPath);
837
- legacy.diagnostics?.delete(normalizedPath);
838
- legacy.diagnosticTimestamps?.delete(normalizedPath);
839
- }
840
- /**
841
- * #1639: `durationMs` measures the PULL SETTLE ITSELF — the caller passes how
842
- * long `clientRequestPullDiagnostics` (plus any retries) took to resolve
843
- * `found`/`clean` — never time-since-didOpen. Document age is a genuinely
844
- * useful signal but a DIFFERENT one; it stays under its own honest name in
845
- * `metadata.elapsedSinceDidOpenMs`, exactly like the push-path `logSequence`
846
- * above already does. Before this fix the top-level field carried the same
847
- * age value pull requests use for retry-budget bookkeeping, so a session's
848
- * latency percentiles for this phase read multi-second document lifetimes as
849
- * millisecond operation costs (147/239 records read >60s "durations" for
850
- * settles that took milliseconds).
851
- *
852
- * `version` reads the real tracked version from `diagnosticsVersionsByPath`
853
- * (bumped by `bumpDiagnosticsVersion` right after this pull's diagnostics
854
- * were stored — see the `documentPullDiagnostics.set` call above it) instead
855
- * of a hardcoded `null`, which made every pull settle look "unbound" to
856
- * staleness forensics even though this codebase already tracks a real
857
- * version for the path. Falls back to the explicit `"pull-unversioned"`
858
- * marker only when nothing has been stored yet, so "no data" is never
859
- * confused with "confirmed no version" (`null`).
860
- *
861
- * `pullSettleSource` distinguishes the two legitimate call paths that can
862
- * both settle a pull for the same file close together: a real
863
- * content-collecting touch ("pull") and `ensureWarmForSweep`'s warm-up-only
864
- * touch ("pull-warmup"), which runs a real round trip purely to prove the
865
- * server answers before the sweep's real touch immediately follows it.
866
- * Before this fix both logged identically as settleSource "pull", so a
867
- * session with warm-up sweeps double-counted: 239 records against 145
868
- * touches, with same-file pairs ~60ms apart and near-identical document
869
- * ages — the warm-up settle and the real touch's settle for the same file.
870
- */
871
- function logTypeScriptPullSettle(state, normalizedPath, durationMs, pullSettleSource) {
872
- if (state.serverId !== "typescript")
873
- return;
874
- const diagnostics = state.documentPullDiagnostics.get(normalizedPath) ?? [];
875
- const elapsedSinceDidOpenMs = Math.max(0, Date.now() - (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
876
- const diagnosticCodes = [
877
- ...new Set(diagnostics
878
- .map((diagnostic) => diagnostic.code)
879
- .filter((code) => code !== undefined)
880
- .map(String)),
881
- ].slice(0, 8);
882
- const trackedVersion = state.diagnosticsVersionsByPath?.get(normalizedPath);
883
- logLatency({
884
- type: "phase",
885
- phase: "lsp_typescript_diagnostic_sequence",
886
- filePath: normalizedPath,
887
- durationMs,
888
- metadata: {
889
- serverId: state.serverId,
890
- outcome: "settled",
891
- launchVariant: state.launchVariant ?? "unknown",
892
- publicationIndex: state.diagnosticPublicationCounts.get(normalizedPath) ?? 0,
893
- version: trackedVersion ?? "pull-unversioned",
894
- diagnosticCount: diagnostics.length,
895
- diagnosticCodes,
896
- elapsedSinceDidOpenMs,
897
- settledReturn: true,
898
- settleSource: pullSettleSource,
899
- },
900
- });
901
- }
902
- /**
903
- * #2066: the ONE full-document walk a send needs, replacing the two that used
904
- * to run independently — a `charCodeAt` loop counting `\n` for the
905
- * `lsp_document_send` line count, and a `.split(/\r\n|\r|\n/)` in
906
- * `buildContentChanges` that materialized one substring per line to read the
907
- * last one. The #1095 sha256 is a third walk and stays; it is native, and it
908
- * is load-bearing.
909
- *
910
- * The two counts here are deliberately NOT the same number and must not be
911
- * collapsed into one. `lfNewlineCount` counts `\n` only, because its consumer
912
- * pairs against `diagnostic_past_eof`, whose gate counts LF BYTES
913
- * (`clients/diagnostic-line-freshness.ts`); merging the conventions would put
914
- * the two records one apart on exactly the mixed-ending documents the pairing
915
- * exists to debug. `lastLine` uses the full LSP terminator set (`\r\n`, lone
916
- * `\r`, `\n` — #1669 review F6), because a CRLF or classic-Mac document whose
917
- * terminators went uncounted computes a range ending mid-document.
918
- */
919
- function scanSentContent(content) {
920
- let lfNewlineCount = 0;
921
- let index = 0;
922
- let start = 0;
923
- // `charCodeAt`, not `codePointAt` (typescript:S7758, accepted): both are
924
- // compared against 10/13, which no surrogate unit or astral code point can
925
- // equal, so they are interchangeable here — but `codePointAt` measured 8%
926
- // slower on a 400 KiB LF document (0.716 vs 0.663 ms/scan), because every
927
- // ordinary character pays a surrogate-pair check this loop never uses.
928
- for (let i = 0; i < content.length; i++) {
929
- const code = content.charCodeAt(i);
930
- if (code !== 10 && code !== 13)
931
- continue;
932
- if (code === 10) {
933
- lfNewlineCount++;
934
- }
935
- else if (content.charCodeAt(i + 1) === 10) {
936
- // `\r\n` is ONE line terminator, and still carries the `\n` the
937
- // LF-only count wants.
938
- lfNewlineCount++;
939
- i++;
940
- }
941
- index++;
942
- start = i + 1;
943
- }
944
- return { lfNewlineCount, lastLine: { index, start } };
945
- }
946
- /**
947
- * #1095: fingerprint the EXACT didOpen/didChange payload text at SEND time and
948
- * tag it with the document version it was sent as, so a later
949
- * `publishDiagnostics` echoing that version can bind its diagnostics to the
950
- * content they were computed against. Runs on in-memory content — never a disk
951
- * read on the notification path (I1). Bounded by the same file-size gates the
952
- * caller already applies to the content it hands us.
953
- */
954
- function recordSentContent(state, normalizedPath, version, content, coalescedCount = 0) {
955
- const scan = scanSentContent(content);
956
- const previous = state.documentContentHashes.get(normalizedPath);
957
- if (previous && previous.version > version) {
958
- incrementDegradationCount({
959
- kind: "lsp-document-send-order",
960
- subject: `${state.serverId}:${normalizedPath}`,
961
- reason: `recorded version ${version} after newer version ${previous.version}`,
962
- });
963
- return;
964
- }
965
- if (previous?.text !== undefined) {
966
- state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
967
- state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? previous.text.length * 2) -
968
- previous.text.length * 2);
969
- // #2065 fix round 1 F5: the path is about to be rewritten below (and
970
- // possibly re-marked text-bearing) — drop the stale membership first so
971
- // the reinsert further down lands at the END of the recency order
972
- // instead of leaving a duplicate-free but stale position.
973
- state.incrementalTextBearingPaths?.delete(normalizedPath);
974
- }
975
- // #2065 fix round 1 F5: eviction recency now lives in
976
- // `incrementalTextBearingPaths` (above/below), not this map's own
977
- // insertion order — no remaining reader iterates `documentContentHashes`
978
- // for order (every other site below is a keyed `.get`/`.delete`). The
979
- // reinsert is kept anyway so a log or future debug dump that DOES walk
980
- // this map in insertion order still reads "most recently sent last",
981
- // matching the aux set it mirrors, rather than a stale mixed order.
982
- state.documentContentHashes.delete(normalizedPath);
983
- state.documentContentHashes.set(normalizedPath, {
984
- version,
985
- hash: hashDiagnosticContent(content),
986
- // #1669: retain the full text only for Incremental — the sole reader
987
- // (`buildContentChanges`) needs it to compute the NEXT change against
988
- // what the server last saw; Full/None never read this field.
989
- // #2066: `lastLine` is that reader's answer, already computed by the
990
- // scan above — it rides along with the text it describes.
991
- ...(state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL && {
992
- text: content,
993
- lastLine: scan.lastLine,
994
- }),
995
- });
996
- if (state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
997
- state.incrementalTextRetainedEntries =
998
- (state.incrementalTextRetainedEntries ?? 0) + 1;
999
- state.incrementalTextRetainedBytes =
1000
- (state.incrementalTextRetainedBytes ?? 0) + content.length * 2;
1001
- // #2065 fix round 1 F5: track membership in the same insertion (=
1002
- // recency) order as `documentContentHashes` itself, so eviction below
1003
- // never needs to spread and scan the full content-binding map —
1004
- // including paths whose text was already stripped — just to find the
1005
- // oldest text-bearing one.
1006
- if (!state.incrementalTextBearingPaths) {
1007
- state.incrementalTextBearingPaths = new Set();
1008
- }
1009
- state.incrementalTextBearingPaths.add(normalizedPath);
1010
- while ((state.incrementalTextRetainedEntries ?? 0) >
1011
- MAX_INCREMENTAL_TEXT_RETAINED_ENTRIES ||
1012
- (state.incrementalTextRetainedBytes ?? 0) >
1013
- MAX_INCREMENTAL_TEXT_RETAINED_BYTES) {
1014
- const oldestPath = state.incrementalTextBearingPaths
1015
- ?.values()
1016
- .next().value;
1017
- if (oldestPath === undefined)
1018
- break;
1019
- const binding = state.documentContentHashes.get(oldestPath);
1020
- state.incrementalTextBearingPaths.delete(oldestPath);
1021
- if (!binding || binding.text === undefined)
1022
- continue;
1023
- // #2066: `lastLine` is dropped here with the text it describes. The
1024
- // #1095 version/hash binding is what must survive the strip.
1025
- state.documentContentHashes.set(oldestPath, {
1026
- version: binding.version,
1027
- hash: binding.hash,
1028
- });
1029
- state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
1030
- state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? 0) - binding.text.length * 2);
1031
- }
1032
- }
1033
- // #1641 criterion 3: the in-memory document's version + content length AT
1034
- // SEND TIME, so a later "diagnostic cited a line past current disk EOF"
1035
- // record (`diagnostic_past_eof`, clients/diagnostic-line-freshness.ts) can be
1036
- // paired with the send that produced the divergent in-memory document —
1037
- // today only the symptom (the stale citation) is observable; this is the
1038
- // cause side of the same timeline.
1039
- //
1040
- // Review round F4/F1: `contentLineCount` MUST use the same LSP-addressable
1041
- // convention as the gate itself (newline count + 1 — a trailing `\n` adds
1042
- // one more, empty, addressable line; an empty document is still 1 line),
1043
- // or the two records disagree by one at exactly the boundary this counter
1044
- // exists to help debug. #2066: the count comes from `scanSentContent`
1045
- // above, which folded this loop into the walk `buildContentChanges` was
1046
- // already paying for; the LF-only convention is unchanged.
1047
- logLatency({
1048
- type: "phase",
1049
- phase: "lsp_document_send",
1050
- filePath: normalizedPath,
1051
- durationMs: 0,
1052
- metadata: {
1053
- version,
1054
- contentLength: content.length,
1055
- contentLineCount: scan.lfNewlineCount + 1,
1056
- ...(coalescedCount > 0 && { coalescedCount }),
1057
- },
1058
- });
1059
- }
1060
- /**
1061
- * #1669: the `contentChanges` array for a `textDocument/didChange` notification.
1062
- *
1063
- * Full/None (or unrecognized/absent) sync kind: unchanged — a single
1064
- * whole-document `{ text }` event, the shape pi-lens has always sent.
1065
- *
1066
- * Incremental: a server registering Incremental-only expects every change
1067
- * event to carry a `range`; a shapeless whole-document event is out of spec
1068
- * for it. Rather than hand-roll a real diff, send the SAFEST incremental
1069
- * representation of a full update — one ranged edit spanning the entire
1070
- * PREVIOUS document (start of file to its last character), replacing it with
1071
- * the entire new content. That is spec-valid for any Incremental server and
1072
- * semantically identical to the Full-sync event it replaces.
1073
- *
1074
- * The previous document's end position is computed from the text
1075
- * `recordSentContent` retained for THIS path the last time content was sent
1076
- * (reusing that seam rather than a second parallel content store — see its
1077
- * doc comment). No prior text on record (first change since Incremental was
1078
- * negotiated, or the path was never sent before) falls back to the
1079
- * whole-document shape — still spec-valid, and self-heals once the next
1080
- * change has a retained previous text to diff against.
1081
- */
1082
- function buildContentChanges(state, normalizedPath, content) {
1083
- if (state.syncKind !== TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
1084
- return [{ text: content }];
1085
- }
1086
- const previous = state.documentContentHashes.get(normalizedPath);
1087
- if (previous?.text === undefined) {
1088
- return [{ text: content }];
1089
- }
1090
- const previousText = previous.text;
1091
- // #2066: `recordSentContent` scanned this exact text when it sent it, so
1092
- // read where it left the last line instead of walking (and splitting) the
1093
- // document a second time. An entry carrying text without that position was
1094
- // written by something else — resolve it through the SAME scanner, never a
1095
- // second convention that can drift from it.
1096
- const lastLine = previous.lastLine ?? scanSentContent(previousText).lastLine;
1097
- const lastLineText = previousText.slice(lastLine.start);
1098
- return [
1099
- {
1100
- range: {
1101
- start: { line: 0, character: 0 },
1102
- end: {
1103
- line: lastLine.index,
1104
- character: convertCharacterOffset(state.positionEncoding, lastLineText, lastLineText.length),
1105
- },
1106
- },
1107
- text: content,
1108
- },
1109
- ];
1110
- }
1111
- // Methods that can be registered dynamically and map to operationSupport keys
1112
- const DYNAMIC_OPERATION_METHOD_MAP = {
1113
- "textDocument/definition": "definition",
1114
- "textDocument/typeDefinition": "typeDefinition",
1115
- "textDocument/declaration": "declaration",
1116
- "textDocument/references": "references",
1117
- "textDocument/hover": "hover",
1118
- "textDocument/signatureHelp": "signatureHelp",
1119
- "textDocument/documentSymbol": "documentSymbol",
1120
- "workspace/symbol": "workspaceSymbol",
1121
- "textDocument/codeAction": "codeAction",
1122
- "textDocument/rename": "rename",
1123
- "textDocument/implementation": "implementation",
1124
- "textDocument/prepareCallHierarchy": "callHierarchy",
1125
- };
1126
- export function applyDynamicCapabilities(state) {
1127
- const registrations = [...state.dynamicRegistrations.values()];
1128
- const registeredMethods = new Set(registrations.map((r) => r.method));
1129
- const hasDynamicPull = registeredMethods.has("textDocument/diagnostic") ||
1130
- registeredMethods.has("workspace/diagnostic");
1131
- if (hasDynamicPull) {
1132
- state.workspaceDiagnosticsSupport = {
1133
- advertised: true,
1134
- mode: "pull",
1135
- // #1667: workspace-pull support is what the REGISTRATION declares. The
1136
- // spec registers workspace pull as `registerOptions.workspaceDiagnostics`
1137
- // on a `textDocument/diagnostic` registration - `workspace/diagnostic` is
1138
- // not itself a registrable method - so reading the method name alone
1139
- // missed every conforming server and this client never issued a
1140
- // workspace pull for one. The method-name check stays as a fallback for
1141
- // servers that register it as a method anyway.
1142
- workspaceDiagnostics: registrations.some((r) => r.workspaceDiagnostics === true) ||
1143
- registeredMethods.has("workspace/diagnostic"),
1144
- diagnosticProviderKind: "dynamic",
1145
- };
1146
- }
1147
- else if (state.staticDiagnosticsMode === "push-only" &&
1148
- state.workspaceDiagnosticsSupport.diagnosticProviderKind === "dynamic") {
1149
- // Was only dynamically registered, now unregistered — revert to push-only
1150
- state.workspaceDiagnosticsSupport = {
1151
- advertised: false,
1152
- mode: "push-only",
1153
- workspaceDiagnostics: false,
1154
- diagnosticProviderKind: "none",
1155
- };
1156
- }
1157
- for (const [method, key] of Object.entries(DYNAMIC_OPERATION_METHOD_MAP)) {
1158
- if (registeredMethods.has(method)) {
1159
- state.operationSupport[key] = true;
1160
- }
1161
- }
1162
- }
1163
- export function resolveConfigurationSection(initialization, section) {
1164
- if (!initialization)
1165
- return section ? null : {};
1166
- if (!section)
1167
- return initialization;
1168
- let cur = initialization;
1169
- for (const part of section.split(".")) {
1170
- if (typeof cur !== "object" || cur === null || !Object.hasOwn(cur, part)) {
1171
- return null;
1172
- }
1173
- cur = cur[part];
1174
- }
1175
- return cur;
1176
- }
1177
- // Exported (only) so tests can invoke the publishDiagnostics notification
1178
- // handler directly against a mock LSPClientState/connection without spawning
1179
- // a real language server. Not part of the public client API surface.
1180
- export function setupIncomingHandlers(state, initialization, onDiagnosticsPublished) {
1181
- state.connection.onNotification("textDocument/publishDiagnostics", (params) => {
1182
- // #1639: the settle operation's own clock for THIS publish. A
1183
- // "quiet-window" settle's durationMs below measures from here (when
1184
- // this publish arrived and — armed or re-armed — the debounce timer)
1185
- // to when that timer actually fires, i.e. the real debounce wait, not
1186
- // time-since-didOpen.
1187
- const publishReceivedAt = Date.now();
1188
- const filePath = uriToPath(params.uri);
1189
- const normalizedPath = normalizeMapKey(filePath);
1190
- // A server can flush a queued publish after didClose during teardown.
1191
- // Do not resurrect diagnostics or their content binding for a document
1192
- // that is no longer open on this client.
1193
- if (state.closedDocuments?.has(normalizedPath))
1194
- return;
1195
- onDiagnosticsPublished?.(state.serverId);
1196
- const newDiags = normalizeLspDiagnostics(params.diagnostics || []);
1197
- const docVersion = params.version;
1198
- if (PUB_DEBUG) {
1199
- // #1333: PUB_DEBUG gate preserved; sink is extension.log.
1200
- logExtension({
1201
- subsystem: "lsp-pub",
1202
- level: "debug",
1203
- message: `server=${state.serverId} pubVersion=${docVersion} docVersion=${state.documentVersions?.get(normalizedPath)} diags=${newDiags.length}`,
1204
- });
1205
- }
1206
- const strategy = getStrategy(state.serverId, state.launchVariant);
1207
- // Publication counting and code extraction exist only for the
1208
- // TypeScript diagnostic-sequence telemetry; skip the bookkeeping for
1209
- // every other push server on this hot receive path.
1210
- const isTypeScriptTelemetry = state.serverId === "typescript";
1211
- const publicationIndex = isTypeScriptTelemetry
1212
- ? (state.diagnosticPublicationCounts.get(normalizedPath) ?? 0) + 1
1213
- : 0;
1214
- if (isTypeScriptTelemetry) {
1215
- state.diagnosticPublicationCounts.set(normalizedPath, publicationIndex);
1216
- }
1217
- const diagnosticCodes = isTypeScriptTelemetry
1218
- ? [
1219
- ...new Set(newDiags
1220
- .map((diagnostic) => diagnostic.code)
1221
- .filter((code) => code !== undefined)
1222
- .map(String)),
1223
- ].slice(0, 8)
1224
- : [];
1225
- // #1639: `durationMs` measures the settle operation, never
1226
- // time-since-didOpen — document age keeps its own honest name in
1227
- // `metadata.elapsedSinceDidOpenMs`, exactly like the pull-path
1228
- // producer above. Before this fix EVERY call (settled or not) logged
1229
- // the doc-age value as durationMs, which is what actually produced
1230
- // the issue's cited evidence: this push-path producer, not the pull
1231
- // path, emitted all 239 records (147 of them >60s "durations" for
1232
- // settles that took milliseconds; a 61ms same-file pair is this
1233
- // function's OWN unsettled-then-settled shape — the
1234
- // `logSequence(false)` raw-receipt record immediately followed by
1235
- // the `logSequence(true, ...)` settle record for the same publish).
1236
- // - "first-push": settles immediately — the seed-first-push strategy
1237
- // bypasses the debounce timer entirely, so there is no wait to
1238
- // measure; durationMs is 0.
1239
- // - "quiet-window": the real debounce wait, timed from THIS publish's
1240
- // own receipt (a later publish that re-arms the timer captures its
1241
- // OWN `publishReceivedAt`, so a re-armed wait is timed from the
1242
- // push that actually won the debounce, not the first one that lost
1243
- // it).
1244
- // - "publication": a raw per-publication receipt, never itself a
1245
- // settle — durationMs is 0 and `settledReturn` stays false. This
1246
- // is the record that used to log with NO settleSource at all;
1247
- // giving it one closes AC3 (distinguish, don't silently overload,
1248
- // the phase's two shapes) for the pairs that actually occur.
1249
- const logSequence = (settledReturn, settleSource, durationMs) => {
1250
- if (state.serverId !== "typescript")
1251
- return;
1252
- const elapsedSinceDidOpenMs = Math.max(0, Date.now() -
1253
- (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
1254
- logLatency({
1255
- type: "phase",
1256
- phase: "lsp_typescript_diagnostic_sequence",
1257
- filePath: normalizedPath,
1258
- durationMs,
1259
- metadata: {
1260
- serverId: state.serverId,
1261
- outcome: settledReturn ? "settled" : "published",
1262
- launchVariant: state.launchVariant ?? "unknown",
1263
- publicationIndex,
1264
- version: docVersion ?? "push-unversioned",
1265
- diagnosticCount: newDiags.length,
1266
- diagnosticCodes,
1267
- elapsedSinceDidOpenMs,
1268
- settledReturn,
1269
- settleSource,
1270
- },
1271
- });
1272
- };
1273
- // Record the document version these diagnostics were computed against
1274
- // (when the server reports it) so waitForDiagnostics can reject results
1275
- // that lag behind the latest didChange instead of serving them as fresh.
1276
- const recordDocVersion = () => {
1277
- if (docVersion !== undefined) {
1278
- state.diagnosticDocVersions.set(normalizedPath, docVersion);
1279
- }
1280
- recordBinding();
1281
- };
1282
- // #1095: bind the just-stored diagnostics to the content they were
1283
- // computed against. Only when the server reported a version AND we still
1284
- // hold the sent-content fingerprint for exactly that version — otherwise
1285
- // no contentHash is recorded, so the binding reads "unknown" and a
1286
- // version-less server behaves exactly as before. Runs at the same
1287
- // write-time moment as `pushDiagnostics.set` (superseded pushes are
1288
- // dropped before this via `isSupersededPush`, so a binding never lags the
1289
- // latest sent version).
1290
- const recordBinding = () => {
1291
- if (docVersion === undefined) {
1292
- state.diagnosticBindings.delete(normalizedPath);
1293
- return;
1294
- }
1295
- const sent = state.documentContentHashes.get(normalizedPath);
1296
- state.diagnosticBindings.set(normalizedPath, {
1297
- version: docVersion,
1298
- contentHash: sent && sent.version === docVersion ? sent.hash : undefined,
1299
- });
1300
- };
1301
- // Late/superseded-push guard: if the server stamped this push with a
1302
- // version and that version already lags the latest didChange we sent,
1303
- // this is analysis for an edit that's since been overtaken — caching it
1304
- // would let getDiagnostics()/getAllDiagnostics()/pruneDiagnostics() (none
1305
- // of which consult isVersionStale — that check only gates the *wait*
1306
- // helper below) serve stale results as current until the next genuinely
1307
- // fresh push overwrites them. Drop it before it reaches the cache instead.
1308
- // Checked at write time (not at notification-receipt time) so a push that
1309
- // arrives fresh but whose debounce timer fires after a later didChange is
1310
- // still caught. Version-less servers (docVersion undefined) are
1311
- // unaffected — that's an intentional, separate tradeoff (see
1312
- // isVersionStale below), not something this guard touches.
1313
- //
1314
- // Known, deliberately out-of-scope gaps: the pull-diagnostics path
1315
- // (clientRequestPullDiagnostics/clientRequestWorkspaceDiagnostics) has no
1316
- // version stamp to compare against in this codebase's current handling,
1317
- // so nothing analogous is applied there. The other gap this note used to
1318
- // record — `diagnosticsVersion` being a single global counter, so an
1319
- // unrelated path's fresh push satisfied a wait for THIS path — is closed by
1320
- // #1531: every bump also stamps `diagnosticsVersionsByPath`, and both the
1321
- // `minVersion` freshness gate and the auxiliary answered/silent evidence
1322
- // check read that per-path stamp.
1323
- const isSupersededPush = () => {
1324
- if (docVersion === undefined)
1325
- return false;
1326
- const currentVersion = state.documentVersions.get(normalizedPath);
1327
- return currentVersion !== undefined && docVersion < currentVersion;
1328
- };
1329
- // Seed on first push for servers whose first push is known complete.
1330
- // Bypasses the debounce timer entirely — resolves waiting promises immediately.
1331
- if (strategy.seedFirstPush &&
1332
- !state.pushDiagnostics.has(normalizedPath)) {
1333
- if (isSupersededPush())
1334
- return;
1335
- state.pushDiagnostics.set(normalizedPath, newDiags);
1336
- state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
1337
- recordDocVersion();
1338
- bumpDiagnosticsVersion(state, normalizedPath);
1339
- state.diagnosticEmitter.emit("diagnostics", normalizedPath);
1340
- // Immediate settle — no debounce wait to measure.
1341
- logSequence(true, "first-push", 0);
1342
- return;
1343
- }
1344
- // A raw per-publication receipt, not itself a settle.
1345
- logSequence(false, "publication", 0);
1346
- const existingTimer = state.pendingDiagnostics.get(normalizedPath);
1347
- if (existingTimer)
1348
- clearTimeout(existingTimer);
1349
- const timer = setTimeout(() => {
1350
- state.pendingDiagnostics.delete(normalizedPath);
1351
- if (isSupersededPush())
1352
- return;
1353
- state.pushDiagnostics.set(normalizedPath, newDiags);
1354
- state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
1355
- recordDocVersion();
1356
- bumpDiagnosticsVersion(state, normalizedPath);
1357
- state.diagnosticEmitter.emit("diagnostics", normalizedPath);
1358
- // The real debounce wait, timed from THIS publish's own receipt.
1359
- logSequence(true, "quiet-window", Date.now() - publishReceivedAt);
1360
- }, strategy.debounceMs);
1361
- state.pendingDiagnostics.set(normalizedPath, timer);
1362
- });
1363
- state.connection.onRequest("workspace/workspaceFolders", () => [
1364
- { name: "workspace", uri: pathToFileURL(state.root).href },
1365
- ]);
1366
- state.connection.onRequest("client/registerCapability", async (params) => {
1367
- for (const reg of params?.registrations ?? []) {
1368
- const options = reg.registerOptions;
1369
- const commands = Array.isArray(options?.commands)
1370
- ? options.commands.filter((cmd) => typeof cmd === "string")
1371
- : undefined;
1372
- if (reg.id && reg.method) {
1373
- // #1667: keep the whole registration, not just its method. An empty
1374
- // `identifier` is treated as absent - it names no source, and
1375
- // sending `identifier: ""` would be a second, indistinguishable
1376
- // bare pull.
1377
- state.dynamicRegistrations.set(reg.id, {
1378
- method: reg.method,
1379
- ...(typeof options?.identifier === "string" && options.identifier
1380
- ? { identifier: options.identifier }
1381
- : {}),
1382
- ...(typeof options?.workspaceDiagnostics === "boolean"
1383
- ? { workspaceDiagnostics: options.workspaceDiagnostics }
1384
- : {}),
1385
- ...(commands ? { commands } : {}),
1386
- });
1387
- }
1388
- // executeCommand commands can arrive dynamically too — merge them
1389
- // into the allowlist so dynamically-registered commands are runnable.
1390
- if (reg.method === "workspace/executeCommand" && commands) {
1391
- for (const cmd of commands)
1392
- state.advertisedCommands.add(cmd);
1393
- }
1394
- }
1395
- applyDynamicCapabilities(state);
1396
- });
1397
- state.connection.onRequest("client/unregisterCapability", async (params) => {
1398
- const retiredIdentifiers = new Set();
1399
- for (const unreg of params?.unregisterations ?? []) {
1400
- if (!unreg.id)
1401
- continue;
1402
- const registration = state.dynamicRegistrations.get(unreg.id);
1403
- state.dynamicRegistrations.delete(unreg.id);
1404
- if (registration?.method === "textDocument/diagnostic" &&
1405
- registration.identifier) {
1406
- retiredIdentifiers.add(registration.identifier);
1407
- }
1408
- }
1409
- // #1667: a retired source's cached findings and resultId must go with
1410
- // it, or the union keeps serving them. Checked after the deletes above,
1411
- // and only when NO surviving registration still speaks for that
1412
- // identifier.
1413
- const stillRegistered = new Set([...state.dynamicRegistrations.values()]
1414
- .filter((r) => r.method === "textDocument/diagnostic")
1415
- .map((r) => r.identifier));
1416
- for (const identifier of retiredIdentifiers) {
1417
- if (!stillRegistered.has(identifier)) {
1418
- retirePullSource(state, identifier);
1419
- }
1420
- }
1421
- applyDynamicCapabilities(state);
1422
- });
1423
- // Server-initiated edits (the mutation vector for executeCommand). Honored
1424
- // ONLY while an explicit executeCommand is in flight (serverEditsAllowed > 0);
1425
- // an unsolicited applyEdit outside that window is refused so a server can't
1426
- // push edits to disk at will. Applied through the same applyWorkspaceEdit path
1427
- // as every other edit.
1428
- state.connection.onRequest("workspace/applyEdit", async (params) => {
1429
- if (state.serverEditsAllowed <= 0 || !params?.edit) {
1430
- return { applied: false, failureReason: "edit not solicited" };
1431
- }
1432
- // #2450 fix round 3 (F3) removed a `depth === 1` re-check here as
1433
- // dead code. It is not dead — it moved to the WRITE side, where it
1434
- // is the live invariant: `state.activeMutationContext` is assigned
1435
- // only by `syncMutationContextSlot` (below in this file), which
1436
- // populates it exactly when `activeMutationDepth === 1` and
1437
- // empties it on every other frame transition. The depth test
1438
- // therefore still runs, on each entry and each unwind; repeating
1439
- // it here would be a second copy of the same predicate, not an
1440
- // independent gate. What this handler reads is consequently either
1441
- // the context of the single frame currently in flight or
1442
- // `undefined` — never a settled frame's context, and never a
1443
- // context belonging to some other window that is still open
1444
- // (#2479 review rounds 2/3).
1445
- const context = state.activeMutationContext;
1446
- // `workspace/applyEdit` is only ever honored inside the
1447
- // `serverEditsAllowed` window this handler just checked, which is
1448
- // opened exclusively by an executeCommand call — so a fallback here is
1449
- // always executeCommand-solicited, never a bare edit (#2450). This
1450
- // fallback context carries no runtime/cacheManager (there is no live
1451
- // reference to those singletons at this call site); lsp-mutation.ts's
1452
- // bookkeepLspMutation falls back to the mutation bridge for exactly
1453
- // that reason rather than silently dropping the write's bookkeeping.
1454
- const telemetryContext = context ?? {
1455
- cwd: state.root,
1456
- correlationId: newLspMutationCorrelationId(),
1457
- tool: "lsp-workspace-applyEdit",
1458
- source: "lsp-execute-command",
1459
- };
1460
- try {
1461
- await applyWorkspaceEdit(params.edit, state.root, {
1462
- positionEncoding: state.positionEncoding,
1463
- documentVersions: state.documentVersions,
1464
- mutationContext: telemetryContext,
1465
- });
1466
- return { applied: true };
1467
- }
1468
- catch (err) {
1469
- return {
1470
- applied: false,
1471
- failureReason: err instanceof Error ? err.message : String(err),
1472
- };
1473
- }
1474
- });
1475
- // #983: the LSP spec requires the response array to have exactly one entry
1476
- // per requested item, each resolved against that item's `section` (a
1477
- // dot-path into the server's config, e.g. "scan.jobs") — not a fixed
1478
- // single-element array duplicating the whole blob for every item. An item
1479
- // with no `section` gets the whole blob (that's what "no section" means
1480
- // per spec); an unresolvable section gets `null`, never the whole blob.
1481
- state.connection.onRequest("workspace/configuration", async (params) => {
1482
- const items = params?.items ?? [];
1483
- return items.map((item) => resolveConfigurationSection(initialization, item?.section));
1484
- });
1485
- state.connection.onRequest("window/workDoneProgress/create", async () => { });
1486
- // #1669: a server can send `workspace/diagnostic/refresh` (typically after a
1487
- // project-wide config change) to say every pull result it has already
1488
- // reported may be stale. Left unhandled, vscode-jsonrpc replies
1489
- // MethodNotFound to a server explicitly telling us its results are stale —
1490
- // this is exactly the signal `workspacePullResultCache` and the persisted
1491
- // workspace-diagnostics cache want (the #1461 family, server-initiated for
1492
- // once). Reply null per spec and drop both so the NEXT pull recomputes
1493
- // instead of replaying — or inheriting an `unchanged` basis from — results
1494
- // the server just told us to distrust.
1495
- // #1669 review R1: single-flight coalescing latch for the re-pull pool
1496
- // below, scoped to THIS client (one `setupIncomingHandlers` call per
1497
- // connection). A refresh FLOOD — a watch-mode rebuild or a `git checkout`
1498
- // can legitimately fire many `workspace/diagnostic/refresh` requests in a
1499
- // tight burst — used to queue one independent capped pool PER refresh:
1500
- // N refreshes meant N pools running concurrently (peak concurrency N x
1501
- // the per-pool cap) and N full passes over every open document (N x
1502
- // redundant pulls for the SAME documents). `refreshRepullRunning` gates a
1503
- // SECOND pool from ever starting while one is already in flight;
1504
- // `refreshRepullRerunRequested` remembers that at least one more refresh
1505
- // arrived meanwhile and coalesces it into exactly ONE trailing rerun
1506
- // after the current pool finishes, using a FRESH snapshot of open
1507
- // documents at rerun time (not the stale list from whenever the burst
1508
- // started) — so an arbitrarily large burst still costs at most 2 passes
1509
- // and never more than `REFRESH_REPULL_CONCURRENCY` pulls at once.
1510
- let refreshRepullRunning = false;
1511
- let refreshRepullRerunRequested = false;
1512
- const runRefreshRepullPool = async () => {
1513
- do {
1514
- refreshRepullRerunRequested = false;
1515
- const toRepull = state.workspaceDiagnosticsSupport.mode === "pull"
1516
- ? [...state.openDocuments]
1517
- : [];
1518
- if (toRepull.length > 0) {
1519
- try {
1520
- await mapWithConcurrency(toRepull, REFRESH_REPULL_CONCURRENCY, async (normalizedPath) => {
1521
- const uri = state.openDocumentUris?.get(normalizedPath);
1522
- const filePath = uri ? uriToPath(uri) : normalizedPath;
1523
- await clientRequestPullDiagnostics(state, filePath);
1524
- });
1525
- }
1526
- catch (err) {
1527
- // #1669 review N2: rejections are observed, never
1528
- // `void`-swallowed — `clientRequestPullDiagnostics` itself never
1529
- // throws (it returns an `unavailable` outcome), so this only
1530
- // fires on a genuine programming error, but a silent throw
1531
- // inside `mapWithConcurrency`'s `Promise.all` must not vanish.
1532
- incrementDegradationCount({
1533
- kind: "lsp-pull-unconfirmed",
1534
- subject: state.serverId,
1535
- reason: `refresh re-pull threw: ${err instanceof Error ? err.message : String(err)}`,
1536
- });
1537
- }
1538
- }
1539
- // A refresh that arrived WHILE this pool was running gets exactly
1540
- // one more pass, never a pass per refresh.
1541
- } while (refreshRepullRerunRequested);
1542
- refreshRepullRunning = false;
1543
- };
1544
- state.connection.onRequest("workspace/diagnostic/refresh", async () => {
1545
- state.workspacePullResultCache.clear();
1546
- // #1669 review F1: clear every on-disk sweep cache this process has
1547
- // recorded a cwd for — `state.root` alone is a per-SERVER identity
1548
- // marker that a monorepo/multi-root project routinely nests BELOW the
1549
- // real sweep cwd, so clearing ONLY there would miss the cache file the
1550
- // sweep actually reads/writes. See the doc comment on
1551
- // `clearAllWorkspaceDiagnosticsCaches`.
1552
- clearAllWorkspaceDiagnosticsCaches();
1553
- // #1669 review N3: ALSO clear at `state.root` directly. The registry
1554
- // above only knows cwds a sweep has already run under IN THIS PROCESS —
1555
- // a refresh arriving before this project's first sweep this process
1556
- // (e.g. right after a respawn) would otherwise clear nothing at all,
1557
- // and a later sweep would load the untouched, now-genuinely-stale
1558
- // on-disk cache a PRIOR process/session left behind. `state.root` is
1559
- // the one cwd this handler can always reach regardless of registry
1560
- // state — a cheap, durable backstop for the common single-root case
1561
- // where root and sweep cwd coincide. It does NOT cover a monorepo
1562
- // subproject a sweep hasn't reached yet under a DIFFERENT cwd than
1563
- // `state.root` — that residual gap is tracked in #1707, not silently
1564
- // claimed as solved (see `clearAllWorkspaceDiagnosticsCaches`'s doc
1565
- // comment for the full shape).
1566
- clearWorkspaceDiagnosticsCacheAtAndAbove(state.root);
1567
- // #1669 review F3: a server-initiated "everything may be stale" signal
1568
- // must drop the SAME per-document state a normal resync already drops
1569
- // via `clearDiagnosticsForPath` (pullResultIds, pushDiagnostics,
1570
- // documentPullDiagnostics, diagnosticBindings,
1571
- // diagnosticsVersionsByPath, ...) — dropping only
1572
- // `workspacePullResultCache` above left every open document's OTHER
1573
- // pull state intact, so the next pull would echo a disowned
1574
- // `previousResultId`, get back `kind: "unchanged"`, and re-confirm
1575
- // stale diagnostics under a fresh timestamp; nothing would ever
1576
- // re-pull on its own. This clear is cheap and always runs — every
1577
- // refresh in a burst gets its own, unlike the re-pull pool below.
1578
- for (const normalizedPath of state.openDocuments) {
1579
- clearDiagnosticsForPath(state, normalizedPath);
1580
- }
1581
- // #1669 review N2: reply to the PROTOCOL request now — every clear
1582
- // above is synchronous and already complete, so there is nothing left
1583
- // this reply should wait on. The re-pull below is a background
1584
- // improvement (so an open document reflects the refresh without
1585
- // waiting on its next edit), never something worth delaying — or
1586
- // risking — the required `null` reply over.
1587
- if (state.workspaceDiagnosticsSupport.mode === "pull" &&
1588
- state.openDocuments.size > 0) {
1589
- if (refreshRepullRunning) {
1590
- // #1669 review R1: a pool is already in flight from an earlier
1591
- // refresh in this burst — coalesce instead of starting a second
1592
- // one.
1593
- refreshRepullRerunRequested = true;
1594
- }
1595
- else {
1596
- refreshRepullRunning = true;
1597
- // `setImmediate` defers even the SYNCHRONOUS prefix of the pool
1598
- // (URI lookups, map bookkeeping) to the next tick, so it can
1599
- // never compete with this reply for the current one regardless
1600
- // of how many documents are open.
1601
- setImmediate(() => {
1602
- void runRefreshRepullPool();
1603
- });
1604
- }
1605
- }
1606
- return null;
1607
- });
1608
- }
1609
- /**
1610
- * #1127: record the FIRST moment this client observed its own death. Detection
1611
- * of a dead client (the next `getClientForFile` attach in index.ts) can happen
1612
- * long after the process actually died — this timestamp is the only reliable
1613
- * "when did it die" signal, so it must be set here, at the earliest death
1614
- * signal, not derived later from detection time.
1615
- */
1616
- function markExitedIfUnset(state) {
1617
- if (state.exitedAt === undefined) {
1618
- state.exitedAt = Date.now();
1619
- }
1620
- }
1621
- function setupConnectionLifecycle(state, recentStderr) {
1622
- state.connection.onError(([error]) => {
1623
- state.lastError = error instanceof Error ? error : new Error(String(error));
1624
- state.isConnected = false;
1625
- state.isDestroyed = true;
1626
- markExitedIfUnset(state);
1627
- disposeClientConnection(state);
1628
- });
1629
- state.connection.onClose(() => {
1630
- state.isConnected = false;
1631
- state.isDestroyed = true;
1632
- markExitedIfUnset(state);
1633
- disposeClientConnection(state);
1634
- });
1635
- state.lspProcess.process.on("exit", (code, signal) => {
1636
- // Gate on shutdownRequested (our own clientShutdown() call), not
1637
- // isConnected: a genuine crash's connection.onClose/onError handler above
1638
- // can fire and flip isConnected false BEFORE this 'exit' event arrives,
1639
- // which used to make the old `wasConnected` check silently swallow every
1640
- // crash whose transport died before the process itself reported exiting
1641
- // (previously: 5 ast-grep deaths during a dogfooding sweep logged only
1642
- // "respawn, uptime=Xms" — no exit code, no signal, no stderr — because
1643
- // none of them tripped this log).
1644
- const wasIntentional = state.shutdownRequested;
1645
- state.isConnected = false;
1646
- state.isDestroyed = true;
1647
- markExitedIfUnset(state);
1648
- disposeClientConnection(state);
1649
- if (!wasIntentional) {
1650
- logLatency({
1651
- type: "phase",
1652
- phase: "lsp_server_unexpected_exit",
1653
- filePath: state.root,
1654
- durationMs: 0,
1655
- metadata: {
1656
- serverId: state.serverId,
1657
- pid: state.lspProcess.pid,
1658
- exitCode: code ?? null,
1659
- exitSignal: signal ?? null,
1660
- stderrTail: recentStderr(20),
1661
- },
1662
- });
1663
- }
1664
- });
1665
- // #1969: the exit CAUSE, in the degradation ledger.
1666
- //
1667
- // The `exit` log above is one latency.log line per death. That line is
1668
- // necessary but not sufficient: an ast-grep child died 14 times in one day
1669
- // with `code=1` and empty stderr, and the only thing visible downstream was
1670
- // 19 `lsp_client_skipped_broken` cooldowns and 32 coverage gaps — fallout
1671
- // with no cause attached. The ledger is what a session-end health read
1672
- // looks at, so the cause belongs there too, counted per server.
1673
- //
1674
- // `close` rather than `exit`: `close` fires only after the child's stdio
1675
- // streams have drained, so "stderr was empty" is a statement about the
1676
- // server rather than a race against the pipe. `exit` can arrive first with
1677
- // stderr still buffered.
1678
- //
1679
- // Gated on `shutdownRequested` exactly as the exit log is — an eviction or
1680
- // an ordinary teardown is not a degradation and must not be recorded.
1681
- // `incrementDegradationCount` counts every occurrence and retains the
1682
- // latest reason per server, so a server dying in a loop shows as one entry
1683
- // with an honest count rather than N entries.
1684
- state.lspProcess.process.on("close", (code, signal) => {
1685
- if (state.shutdownRequested)
1686
- return;
1687
- try {
1688
- const stderrTail = recentStderr(20);
1689
- incrementDegradationCount({
1690
- kind: "lsp-server-unexpected-close",
1691
- subject: state.serverId,
1692
- reason: `closed unprompted: code=${code ?? "none"} signal=${signal ?? "none"} ` +
1693
- `stderr=${stderrTail.length > 0 ? `present(${stderrTail.length}b)` : "empty"} root=${state.root}`,
1694
- });
1695
- }
1696
- catch {
1697
- // Telemetry must never break the observed path.
1698
- }
1699
- });
1700
- }
1701
- /** Margin between each source's own request timeout and the race's backstop
1702
- * deadline, so the natural "all sources settled" path wins over the backstop
1703
- * and the race never reports `unavailable` for sources that did answer. */
1704
- const PULL_RACE_BACKSTOP_MARGIN_MS = 25;
1705
- /**
1706
- * #1667: pull EVERY registered diagnostic source for a file, in parallel, and
1707
- * answer as soon as the first source returns findings for that file.
1708
- *
1709
- * Roslyn, vtsls and some Java setups register `textDocument/diagnostic` once
1710
- * per source (syntax, semantic, analyzers), each named by
1711
- * `registerOptions.identifier`. One bare request asks only the server's default
1712
- * source, so whole categories of findings were never requested. The fan-out
1713
- * asks all of them plus the bare request.
1714
- *
1715
- * Timing is deliberate and load-bearing:
1716
- * - PARALLEL, never one source after another. Sequencing would add every
1717
- * slow source's latency to every touch.
1718
- * - The race resolves on the FIRST source with findings for the file, with no
1719
- * settle or debounce window after the match. A post-match wait is the exact
1720
- * latency shape #1112/#1407 already cost this codebase once.
1721
- * - Losing sources are NOT cancelled. Each source writes its own findings into
1722
- * the pull cache before it resolves, so a late answer merges in the
1723
- * background and the NEXT read of the file sees it. Late answers are not
1724
- * dropped, they are just not waited for.
1725
- *
1726
- * The race runs inside the existing `raceToCompletion` primitive rather than a
1727
- * second first-wins helper.
1728
- */
1729
- async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQUEST_TIMEOUT_MS) {
1730
- if (!isClientAlive(state))
1731
- return { status: "unavailable" };
1732
- const sources = pullDiagnosticSources(state);
1733
- if (sources.length === 1) {
1734
- // The overwhelmingly common case: no named sources registered. Skip the
1735
- // race entirely so a single-source server pays nothing for this feature.
1736
- return aggregatePullOutcomes(state, filePath, [
1737
- await pullDiagnosticSource(state, filePath, budgetMs, undefined),
1738
- ]);
1739
- }
1740
- const attempts = sources.map((identifier) => pullDiagnosticSource(state, filePath, budgetMs, identifier));
1741
- const settled = await raceToCompletion(attempts, (results) => results.some((r) => r.status === "found" && r.primaryCount > 0), {
1742
- // No graceMs: finalize the instant a source answers for this file.
1743
- // The per-request `withTimeout` inside each attempt is the real bound;
1744
- // this deadline is only a backstop, so it sits just past it.
1745
- timeoutMs: Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)) +
1746
- PULL_RACE_BACKSTOP_MARGIN_MS,
1747
- });
1748
- return aggregatePullOutcomes(state, filePath, settled);
1749
- }
1750
- /**
1751
- * Collapse the sources that answered into one outcome for the caller.
1752
- * `found` beats `clean` beats `unavailable`: a source with findings is an
1753
- * answer, an authoritative empty report is an answer, and only "nobody
1754
- * answered" is unavailable (#240 — an empty result must distinguish clean
1755
- * from errored).
1756
- */
1757
- function aggregatePullOutcomes(state, filePath, outcomes) {
1758
- const found = outcomes.filter((o) => o.status === "found");
1759
- if (found.length > 0) {
1760
- const normalizedPath = normalizeMapKey(filePath);
1761
- // The union for the requested file, which is what a caller reading the
1762
- // cache will see. A source that only reported `relatedDocuments` findings
1763
- // contributes nothing there, so fall back to its own count.
1764
- const unionCount = state.documentPullDiagnostics.get(normalizedPath)?.length ?? 0;
1765
- return {
1766
- status: "found",
1767
- count: Math.max(unionCount, ...found.map((o) => o.count)),
1768
- };
1769
- }
1770
- // #240, applied across sources: `clean` is an authoritative "this file has no
1771
- // findings", and one source saying so proves nothing about a source that
1772
- // FAILED to answer. A fan-out where any source errored is therefore
1773
- // unavailable, not clean - the caller falls through to the push-wait/timeout
1774
- // backstop instead of recording a confirmed-clean touch it cannot support.
1775
- // (`raceToCompletion` drops still-pending promises from its result array, so
1776
- // "every source settled, none errored" is the only shape that reads clean.)
1777
- if (outcomes.some((o) => o.status === "unavailable")) {
1778
- return { status: "unavailable" };
1779
- }
1780
- return outcomes.some((o) => o.status === "clean")
1781
- ? { status: "clean" }
1782
- : { status: "unavailable" };
1783
- }
1784
- async function pullDiagnosticSource(state, filePath, budgetMs, identifier) {
1785
- if (!isClientAlive(state))
1786
- return { status: "unavailable" };
1787
- const normalizedPath = normalizeMapKey(filePath);
1788
- // #1773: a budget at or below the usable floor cannot complete a real
1789
- // round trip. Skip the dispatch entirely — no `safeSendRequest` call, no
1790
- // generation/sequence claim — and say so with a distinct record, so
1791
- // `lsp_pull_diagnostic_timeout` only ever means a pull was genuinely
1792
- // attempted. The caller sees the same `unavailable` outcome a timeout
1793
- // would have produced (#240: unavailable, never a false clean), so
1794
- // nothing downstream of this call changes shape.
1795
- if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
1796
- emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${pullSourceKey(normalizedPath, identifier)}`, {
1797
- type: "phase",
1798
- filePath: normalizedPath,
1799
- durationMs: 0,
1800
- metadata: {
1801
- identifier: identifier ?? "bare",
1802
- remainingBudgetMs: budgetMs,
1803
- server: state.serverId,
1804
- },
1805
- }, {
1806
- ledgerKind: "lsp-pull-skipped-budget-exhausted",
1807
- reason: `pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
1808
- });
1809
- return { status: "unavailable" };
1810
- }
1811
- const uri = pathToFileURL(filePath).href;
1812
- // #1104: echo the last resultId we hold for this document so a server that
1813
- // hasn't changed its view can answer `kind: "unchanged"` instead of
1814
- // recomputing — see the `kind === "unchanged"` branch below for how that's
1815
- // honored (inherit, never treat an omitted `items` as clean).
1816
- // #1667: read THIS source's id, never another source's. Result ids are
1817
- // issued per source, so echoing a syntax id on a semantic request asks the
1818
- // server to compare against a basis it never handed out.
1819
- const sourceKey = pullSourceKey(normalizedPath, identifier);
1820
- if (state.abandonedPullRequests?.has(sourceKey)) {
1821
- return { status: "unavailable" };
1822
- }
1823
- const previousResultId = state.pullResultIds.get(sourceKey);
1824
- // #1667: the generation this pull was computed against. A resync landing
1825
- // while the request is in flight bumps it, and every write below is dropped
1826
- // rather than resurrecting state the resync cleared.
1827
- const generation = pullGenerationFor(state, normalizedPath);
1828
- // #1667: claim the newest-request slot for THIS source. Two fan-outs can
1829
- // overlap for the same file with no resync between them (`ensureWarmForSweep`
1830
- // touches, then the real touch follows ~60ms later), so the generation check
1831
- // alone would let a slow answer from the earlier round land on top of a newer
1832
- // one. Re-checked at write time below.
1833
- const requestSequence = claimPullRequestSequence(state, sourceKey);
1834
- // #1889: give the request a cancellation token. `withTimeout` only abandons
1835
- // its await; aborting in the timeout branch below also sends `$/cancelRequest`
1836
- // so repeated touches cannot leave an aging backlog inside the server.
1837
- const requestStartedAt = Date.now();
1838
- const effectiveTimeoutMs = Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs));
1839
- const pullAbort = new AbortController();
1840
- const pullSettlement = { cancelled: false };
1841
- const requestPromise = safeSendRequest(state.connection, "textDocument/diagnostic", {
1842
- textDocument: { uri },
1843
- // #1667: name the source this request is for, so the server answers
1844
- // from that source instead of its default one.
1845
- ...(identifier !== undefined && { identifier }),
1846
- ...(previousResultId !== undefined && { previousResultId }),
1847
- }, pullAbort.signal, pullSettlement);
1848
- try {
1849
- // withTimeout is the backstop against a hung pull-mode server: without it
1850
- // this await never settles unless the stream is destroyed. Bounded by the
1851
- // smaller of the absolute ceiling and the caller's remaining wait budget.
1852
- // On timeout the caught error yields `unavailable` below (never a false
1853
- // `clean`), so it falls through to the push-wait/timeout backstop.
1854
- const report = await withTimeout(requestPromise, effectiveTimeoutMs);
1855
- if (!report) {
1856
- recordPullFailure(state, "textDocument/diagnostic", new Error("empty response"));
1857
- return { status: "unavailable" };
1858
- }
1859
- const now = Date.now();
1860
- // #1104: the fingerprint of the content we last sent for this document —
1861
- // the SAME `documentContentHashes` entry the push binding path uses
1862
- // (`recordSentContent` runs unconditionally on every didOpen/didChange,
1863
- // regardless of push/pull mode), so this costs no extra read. A pull
1864
- // response describes whatever the server had when it answered, which for
1865
- // a pi-lens-opened document is exactly that last-sent payload.
1866
- const sentHash = state.documentContentHashes.get(normalizedPath)?.hash;
1867
- // #1667: two ways this answer can already be obsolete, and neither may
1868
- // write. Report the round trip as unavailable - a superseded answer is not
1869
- // evidence the file is clean (#240).
1870
- // - A resync landed while the request was in flight, so these findings
1871
- // describe content that no longer exists.
1872
- // - A LATER request for this same source has already been issued (an
1873
- // overlapping fan-out), so this answer is the stale loser and would
1874
- // clobber the newer result.
1875
- if (pullGenerationFor(state, normalizedPath) !== generation ||
1876
- !isNewestPullRequest(state, sourceKey, requestSequence)) {
1877
- return { status: "unavailable" };
1878
- }
1879
- let primaryCount;
1880
- if (report.kind === "unchanged") {
1881
- // #1104: same resultId basis as last time — an omitted `items` here
1882
- // means "no change", NOT "clean". Overwriting with `[]` would be the
1883
- // exact false-clean shape #570/#571 already fixed for the touch path;
1884
- // keep the previously stored diagnostics and binding as-is.
1885
- // #1667: "the previously stored diagnostics" means THIS SOURCE's, so
1886
- // one source reporting unchanged neither inherits nor disturbs another
1887
- // source's findings for the same file.
1888
- primaryCount = pullSourceDiagnostics(state, normalizedPath, identifier).length;
1889
- // Still a fresh confirmation as of `now` even though the content is
1890
- // unchanged — bump the timestamp so `getAllDiagnostics()` doesn't read
1891
- // this entry as aging purely because the server had nothing new to say.
1892
- state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
1893
- }
1894
- else {
1895
- const primaryItems = normalizeLspDiagnostics(report.items ?? []);
1896
- // #1667: replace only this source's slice; `documentPullDiagnostics`
1897
- // becomes the deduped union of every source that has reported.
1898
- storePullSourceDiagnostics(state, normalizedPath, identifier, primaryItems);
1899
- state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
1900
- bumpDiagnosticsVersion(state, normalizedPath);
1901
- state.diagnosticBindings.set(normalizedPath, { contentHash: sentHash });
1902
- primaryCount = primaryItems.length;
1903
- }
1904
- let totalCount = primaryCount;
1905
- if (report.resultId !== undefined) {
1906
- state.pullResultIds.set(sourceKey, report.resultId);
1907
- }
1908
- else {
1909
- state.pullResultIds.delete(sourceKey);
1910
- }
1911
- // #1531 note (pre-existing, unchanged): a related document's diagnostics are
1912
- // STORED below but earn no version bump and therefore no per-path stamp —
1913
- // exactly as before, since this loop never bumped the global counter either.
1914
- // A wait on a path that only ever hears about itself through some other
1915
- // document's `relatedDocuments` sees no freshness evidence and rides its
1916
- // timeout. Direction is under-detection, and correcting it means deciding
1917
- // what a related-document publication is evidence OF (its binding is
1918
- // honestly "unknown" per #1104), so it stays out of scope here.
1919
- if (report.relatedDocuments) {
1920
- for (const [relatedUri, related] of Object.entries(report.relatedDocuments)) {
1921
- const relatedPath = uriToPath(relatedUri);
1922
- const relatedNormalized = normalizeMapKey(relatedPath);
1923
- // #1667: a related document's report belongs to the SAME source as
1924
- // the request that carried it, so it stores and inherits under that
1925
- // source's key too.
1926
- if (related?.kind === "unchanged") {
1927
- totalCount += pullSourceDiagnostics(state, relatedNormalized, identifier).length;
1928
- state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
1929
- }
1930
- else {
1931
- const relatedItems = normalizeLspDiagnostics(related?.items ?? []);
1932
- storePullSourceDiagnostics(state, relatedNormalized, identifier, relatedItems);
1933
- state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
1934
- // #1104: a related document's diagnostics were NOT computed against
1935
- // content we independently sent/fingerprinted (we never requested
1936
- // it directly) — its binding stays honestly "unknown" rather than
1937
- // borrowing the primary document's hash.
1938
- state.diagnosticBindings.delete(relatedNormalized);
1939
- totalCount += relatedItems.length;
1940
- }
1941
- if (related?.resultId !== undefined) {
1942
- state.pullResultIds.set(pullSourceKey(relatedNormalized, identifier), related.resultId);
1943
- }
1944
- }
1945
- }
1946
- state.diagnosticEmitter.emit("diagnostics", normalizedPath);
1947
- return totalCount > 0
1948
- ? { status: "found", count: totalCount, primaryCount }
1949
- : { status: "clean" };
1950
- }
1951
- catch (err) {
1952
- if (isPullTimeoutError(err, effectiveTimeoutMs)) {
1953
- trackAbandonedPullRequest(state, sourceKey, requestPromise);
1954
- pullAbort.abort();
1955
- recordPullTimeoutTelemetry({
1956
- scope: normalizedPath,
1957
- identifier,
1958
- effectiveBudgetMs: effectiveTimeoutMs,
1959
- hadPreviousResultId: previousResultId !== undefined,
1960
- elapsedMs: Date.now() - requestStartedAt,
1961
- server: state.serverId,
1962
- });
1963
- armLateAnswerTelemetry({
1964
- requestPromise,
1965
- scope: normalizedPath,
1966
- identifier,
1967
- subject: sourceKey,
1968
- requestStartedAt,
1969
- server: state.serverId,
1970
- settlement: pullSettlement,
1971
- });
1972
- }
1973
- recordPullFailure(state, "textDocument/diagnostic", err);
1974
- return { status: "unavailable" };
1975
- }
1976
- }
1977
- function trackAbandonedPullRequest(state, sourceKey, requestPromise) {
1978
- if (!state.abandonedPullRequests)
1979
- state.abandonedPullRequests = new Map();
1980
- state.abandonedPullRequests.set(sourceKey, requestPromise);
1981
- const release = () => {
1982
- if (state.abandonedPullRequests?.get(sourceKey) === requestPromise) {
1983
- state.abandonedPullRequests.delete(sourceKey);
1984
- }
1985
- };
1986
- requestPromise.then(release, release);
1987
- }
1988
- /**
1989
- * #1713: `withTimeout` rejects with this EXACT message (`clients/deadline-utils.ts`)
1990
- * only when its own timer wins the race — every other rejection is the request
1991
- * itself failing (bad params, dead connection, thrown by the server). Only the
1992
- * timer-won case is a "the answer might still be coming" situation worth a
1993
- * late-answer watch; a request that already failed fast has nothing further to
1994
- * observe.
1995
- */
1996
- function isPullTimeoutError(err, timeoutMs) {
1997
- return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
1998
- }
1999
- /**
2000
- * #1713: every pull timeout emits exactly one bounded latency.log record — the
2001
- * observability gap the issue names (`withTimeout`'s throw previously skipped
2002
- * ALL telemetry for this site). Never throws: telemetry must not perturb the
2003
- * timeout path, which has already decided to return `unavailable` regardless.
2004
- *
2005
- * #1743: routed through `emitBounded`. This phase takes NO rising-edge gate
2006
- * and NO per-turn cap, which is the pre-existing behavior kept exactly: a
2007
- * pull timeout costs at least the full budget (seconds), so the call cadence
2008
- * already bounds the volume, unlike `navRequest` below. The helper still
2009
- * supplies the identity discipline and the registry membership the sweep
2010
- * checks.
2011
- */
2012
- function recordPullTimeoutTelemetry(args) {
2013
- // #1771: this record is written on a genuine failure path (a dispatched
2014
- // pull that never answered), so per the bounded-telemetry rule
2015
- // (`clients/bounded-telemetry.ts`) it needs a `ledgerKind`, not just the
2016
- // detailed latency.log record. Before this it counted nothing in the
2017
- // degradation ledger — every occurrence wrote a detail line, but nothing
2018
- // tallied. No `risingEdgePer`: timeouts repeat and every one should count,
2019
- // matching the pre-existing "no rising-edge gate" behavior for the
2020
- // detailed record itself.
2021
- emitBounded("lsp_pull_diagnostic_timeout", `${args.server}::${args.scope}::${args.identifier ?? "bare"}`, {
2022
- type: "phase",
2023
- filePath: args.scope,
2024
- durationMs: args.elapsedMs,
2025
- metadata: {
2026
- identifier: args.identifier ?? "bare",
2027
- effectiveBudgetMs: args.effectiveBudgetMs,
2028
- hadPreviousResultId: args.hadPreviousResultId,
2029
- server: args.server,
2030
- },
2031
- }, {
2032
- ledgerKind: "lsp-pull-diagnostic-timeout",
2033
- reason: `pull timeout on ${args.server} after ${args.elapsedMs}ms (budget ${args.effectiveBudgetMs}ms)`,
2034
- });
2035
- }
2036
- /**
2037
- * #1713: a telemetry-only continuation on the ALREADY-abandoned request
2038
- * promise. `withTimeout` only raced it against a timer — the request is still
2039
- * live server-side — so if it eventually resolves, this is the only place that
2040
- * ever finds out. Purely observational: it runs after the caller has already
2041
- * moved on with `unavailable`, and it never writes into `state`.
2042
- *
2043
- * Bounded via the degradation-ledger convention (`incrementDegradationCount`,
2044
- * chosen over a hand-rolled per-identity rising-edge Set): the ledger already
2045
- * caps entries per kind and re-arms at `session_start`
2046
- * (`resetDegradationLedger`, wired into `handleSessionStart`), so reusing it
2047
- * avoids a second latch this fix would otherwise have to register and reset
2048
- * itself. `subject` is the same `pullSourceKey` (path + identifier) the pull
2049
- * path already uses, so the discriminating identity survives aggregation.
2050
- *
2051
- * Attaching `.then` here creates no timer, socket, or other handle — it only
2052
- * observes a promise the request already created — so it cannot keep the
2053
- * process alive a moment longer than that pending request already does.
2054
- */
2055
- function armLateAnswerTelemetry(args) {
2056
- args.requestPromise.then(() => {
2057
- if (args.settlement.cancelled)
2058
- return;
2059
- try {
2060
- const elapsedMs = Date.now() - args.requestStartedAt;
2061
- // #1743: `emitBounded` counts every late answer in the ledger
2062
- // under the same `pullSourceKey` subject as before, then writes
2063
- // the record. No rising-edge gate, matching #1713's original
2064
- // behavior: a late answer arrives at most once per abandoned
2065
- // request, so the record count cannot exceed the timeout count.
2066
- emitBounded("lsp_pull_late_answer_discarded", args.subject, {
2067
- type: "phase",
2068
- filePath: args.scope,
2069
- durationMs: elapsedMs,
2070
- metadata: { identifier: args.identifier ?? "bare" },
2071
- }, {
2072
- ledgerKind: "lsp-pull-late-answer",
2073
- reason: `late pull answer discarded after ${elapsedMs}ms`,
2074
- });
2075
- }
2076
- catch {
2077
- // Telemetry must never break the observed path.
2078
- }
2079
- }, (err) => {
2080
- // #1774: the abandoned request eventually REJECTED rather than
2081
- // answering. Behavior is unchanged — the rejection is still
2082
- // swallowed here, exactly as before — but "timeout then silence"
2083
- // and "timeout then server rejection (e.g. ContentModified)" were
2084
- // previously indistinguishable: `recordPullFailure` at request
2085
- // time reports the FIRST failure only, and this rejection happens
2086
- // strictly after that request already timed out, so it needs its
2087
- // own record. Bounded via the degradation ledger, same shape as
2088
- // the late-answer branch above, so this rejection cannot outpace
2089
- // the timeout count that gates it.
2090
- try {
2091
- const elapsedMs = Date.now() - args.requestStartedAt;
2092
- const candidate = err;
2093
- const code = typeof candidate?.code === "number" ||
2094
- typeof candidate?.code === "string"
2095
- ? candidate.code
2096
- : undefined;
2097
- emitBounded("lsp_pull_late_rejection",
2098
- // #1774 review: prefix the server, same reason the timeout kind
2099
- // does (#1771). The workspace call site's `subject` is the bare
2100
- // `WORKSPACE_PULL_SCOPE` constant — without the server prefix,
2101
- // two servers' abandoned-workspace-pull rejections would collapse
2102
- // into one ledger subject and hide which server is storming.
2103
- `${args.server}::${args.subject}`, {
2104
- type: "phase",
2105
- filePath: args.scope,
2106
- durationMs: elapsedMs,
2107
- metadata: {
2108
- identifier: args.identifier ?? "bare",
2109
- server: args.server,
2110
- ...(code !== undefined && { code }),
2111
- },
2112
- }, {
2113
- ledgerKind: "lsp-pull-late-rejection",
2114
- reason: `late pull rejection ${elapsedMs}ms after timeout${code !== undefined ? ` (code ${code})` : ""}`,
2115
- });
2116
- }
2117
- catch {
2118
- // Telemetry must never break the observed path.
2119
- }
2120
- });
2121
- }
2122
- const PULL_FAILURE_HISTORY_LIMIT = 10;
2123
- function recordPullFailure(state, method, error) {
2124
- const candidate = error;
2125
- const message = typeof candidate.message === "string" ? candidate.message : "";
2126
- const unsupportedMessage = /^(?:method not found|unknown method|unsupported method)(?::|$)/i;
2127
- if (candidate.code === -32601 ||
2128
- candidate.code === "-32601" ||
2129
- unsupportedMessage.test(message.trim()))
2130
- return;
2131
- state.pullFailureHistory.push({
2132
- timestamp: Date.now(),
2133
- method,
2134
- ...(typeof candidate.code === "number" || typeof candidate.code === "string"
2135
- ? { code: candidate.code }
2136
- : {}),
2137
- message: typeof candidate.message === "string" ? candidate.message : String(error),
2138
- });
2139
- if (state.pullFailureHistory.length > PULL_FAILURE_HISTORY_LIMIT) {
2140
- state.pullFailureHistory.splice(0, state.pullFailureHistory.length - PULL_FAILURE_HISTORY_LIMIT);
2141
- }
2142
- }
2143
- /**
2144
- * One project-wide `workspace/diagnostic` pull — a single request that returns
2145
- * diagnostics for every document the server knows, instead of opening N files.
2146
- * Returns per-file reports, or `undefined` on unsupported/dead/timeout/malformed
2147
- * (caller falls back to the per-file path). `unchanged`-kind items carry no
2148
- * diagnostics and are skipped, so a file absent from the result is "clean".
2149
- */
2150
- export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
2151
- if (!isClientAlive(state))
2152
- return undefined;
2153
- if (!state.workspaceDiagnosticsSupport.workspaceDiagnostics)
2154
- return undefined;
2155
- if (state.abandonedPullRequests?.has(WORKSPACE_PULL_SCOPE))
2156
- return undefined;
2157
- // #1104: echo every resultId we hold from a PRIOR pull so the server can
2158
- // answer `kind: "unchanged"` for files it hasn't recomputed, instead of
2159
- // resending (and us re-hashing) every file on every sweep.
2160
- const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
2161
- // #1773: same floor as `pullDiagnosticSource` — an exhausted budget here is
2162
- // the SAME clamp-then-dispatch defect via a second entry point. Observed
2163
- // live in the 2026-08-20 plegma dogfood session via
2164
- // `lsp_workspace_diagnostics_start` (not just the `lens_diagnostics_full`
2165
- // fan-out `pullDiagnosticSource` covers), so this call site needs its own
2166
- // skip, not just a shared helper the caller happens to hit.
2167
- if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
2168
- emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${WORKSPACE_PULL_SCOPE}`, {
2169
- type: "phase",
2170
- filePath: WORKSPACE_PULL_SCOPE,
2171
- durationMs: 0,
2172
- metadata: {
2173
- identifier: "bare",
2174
- remainingBudgetMs: budgetMs,
2175
- server: state.serverId,
2176
- },
2177
- }, {
2178
- ledgerKind: "lsp-pull-skipped-budget-exhausted",
2179
- reason: `workspace pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
2180
- });
2181
- return undefined;
2182
- }
2183
- // #1713: declared OUTSIDE the try so the catch below can still reach them —
2184
- // same shape as `pullDiagnosticSource` above. Captured outside `withTimeout`
2185
- // so a timeout still holds a live handle on the request for the late-answer
2186
- // watch.
2187
- const workspacePullStartedAt = Date.now();
2188
- const workspacePullTimeoutMs = Math.max(1, budgetMs);
2189
- const workspacePullAbort = new AbortController();
2190
- const workspacePullSettlement = { cancelled: false };
2191
- const workspaceRequestPromise = safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }, workspacePullAbort.signal, workspacePullSettlement);
2192
- try {
2193
- const report = await withTimeout(workspaceRequestPromise, workspacePullTimeoutMs);
2194
- if (!report || !Array.isArray(report.items))
2195
- return undefined;
2196
- const out = [];
2197
- // #1669 review N1: ONE deadline, shared across every "unchanged, no
2198
- // cached basis" fallback pull this call makes — not a fresh `budgetMs`
2199
- // grant handed to EACH one. A refresh clears `workspacePullResultCache`,
2200
- // so the very next sweep after a refresh can route every item down this
2201
- // path; granting each one the full budget serially turned a bounded call
2202
- // unbounded (measured: 6 files x 300ms budget = 1844ms; worst case is
2203
- // the normal case — 2000 files x 30s). Mirrors the shared-budget shape
2204
- // `runWorkspaceDiagnosticsSwept`'s `perFileMs` bounds each file to,
2205
- // scoped to fallback pulls made from within THIS one call.
2206
- const fallbackPullDeadline = Date.now() + Math.max(1, budgetMs);
2207
- for (const item of report.items) {
2208
- if (!item?.uri)
2209
- continue;
2210
- const filePath = uriToPath(item.uri);
2211
- const normalizedPath = normalizeMapKey(filePath);
2212
- if (item.kind === "unchanged") {
2213
- // #1104: inherit the prior pull's diagnostics + content binding for
2214
- // the SAME resultId basis — an "unchanged" report never carries
2215
- // `items`, so without this a file the server confirmed unchanged
2216
- // would silently drop out of the sweep result entirely.
2217
- const prior = state.workspacePullResultCache.get(normalizedPath);
2218
- if (!prior) {
2219
- // #1669 review F4: no earlier basis to inherit (e.g. right after a
2220
- // `workspace/diagnostic/refresh` cleared `workspacePullResultCache`)
2221
- // — the server is answering "unchanged" against a resultId basis we
2222
- // no longer hold, so there is nothing honest to report for this
2223
- // file from THIS response. `continue`-ing here would make the file
2224
- // silently absent from `out`, which the caller reads as "clean" —
2225
- // a false clean for a file the server just told us it hasn't even
2226
- // looked at freshly. Treat it as needs-full-pull: request the
2227
- // per-file diagnostic directly instead.
2228
- const remainingMs = fallbackPullDeadline - Date.now();
2229
- if (remainingMs <= 0) {
2230
- // #1669 review N1: the shared fallback-pull deadline is already
2231
- // exhausted by earlier files in this same loop — bail WITHOUT
2232
- // attempting a request (never fabricate clean; this file was
2233
- // never actually asked about) and surface it as unavailable via
2234
- // the same degradation ledger path as a real unavailable pull.
2235
- incrementDegradationCount({
2236
- kind: "lsp-pull-unconfirmed",
2237
- subject: state.serverId,
2238
- reason: "unchanged report with no workspacePullResultCache basis; the shared fallback-pull deadline was already exhausted by earlier files this sweep",
2239
- });
2240
- continue;
2241
- }
2242
- const outcome = await clientRequestPullDiagnostics(state, filePath, remainingMs);
2243
- if (outcome.status === "found") {
2244
- out.push({
2245
- filePath,
2246
- diagnostics: state.documentPullDiagnostics.get(normalizedPath) ?? [],
2247
- contentHash: state.diagnosticBindings.get(normalizedPath)?.contentHash,
2248
- });
2249
- }
2250
- else if (outcome.status === "unavailable") {
2251
- // Genuinely unconfirmed — never fabricate a clean result. Recorded
2252
- // via the shared degradation ledger (AGENTS.md: repeated
2253
- // degradations use incrementDegradationCount, not a hand-rolled
2254
- // counter) so a server that keeps racing refresh against sweeps
2255
- // is visible in aggregate instead of silently absorbed per-file.
2256
- incrementDegradationCount({
2257
- kind: "lsp-pull-unconfirmed",
2258
- subject: state.serverId,
2259
- reason: "unchanged report with no workspacePullResultCache basis, and the per-file fallback pull was unavailable",
2260
- });
2261
- }
2262
- // "clean": genuinely clean, correctly absent from `out`.
2263
- continue;
2264
- }
2265
- if (item.resultId !== undefined) {
2266
- state.workspacePullResultCache.set(normalizedPath, {
2267
- ...prior,
2268
- resultId: item.resultId,
2269
- });
2270
- }
2271
- out.push({
2272
- filePath,
2273
- diagnostics: prior.diagnostics,
2274
- contentHash: prior.contentHash,
2275
- });
2276
- continue;
2277
- }
2278
- // "full" (or a non-conforming server omitting `kind`, per the LSP
2279
- // default) — recompute and re-fingerprint.
2280
- const diagnostics = normalizeLspDiagnostics(item.items ?? []);
2281
- // #1104: fingerprint the file bytes active AT REQUEST TIME. Best-effort —
2282
- // a read failure (deleted/unreadable mid-sweep) just leaves contentHash
2283
- // undefined, so the binding reads honestly "unknown", never fabricated.
2284
- let contentHash;
2285
- try {
2286
- contentHash = hashDiagnosticContent(await readFile(filePath, "utf-8"));
2287
- }
2288
- catch {
2289
- contentHash = undefined;
2290
- }
2291
- if (item.resultId !== undefined) {
2292
- if (state.workspacePullResultCache.size >= WORKSPACE_PULL_RESULT_CACHE_MAX) {
2293
- state.workspacePullResultCache.clear();
2294
- }
2295
- state.workspacePullResultCache.set(normalizedPath, {
2296
- uri: item.uri,
2297
- resultId: item.resultId,
2298
- diagnostics,
2299
- contentHash,
2300
- });
2301
- }
2302
- else {
2303
- state.workspacePullResultCache.delete(normalizedPath);
2304
- }
2305
- out.push({ filePath, diagnostics, contentHash });
2306
- }
2307
- return out;
2308
- }
2309
- catch (err) {
2310
- if (isPullTimeoutError(err, workspacePullTimeoutMs)) {
2311
- trackAbandonedPullRequest(state, WORKSPACE_PULL_SCOPE, workspaceRequestPromise);
2312
- workspacePullAbort.abort();
2313
- recordPullTimeoutTelemetry({
2314
- scope: WORKSPACE_PULL_SCOPE,
2315
- identifier: undefined,
2316
- effectiveBudgetMs: workspacePullTimeoutMs,
2317
- hadPreviousResultId: previousResultIds.length > 0,
2318
- elapsedMs: Date.now() - workspacePullStartedAt,
2319
- server: state.serverId,
2320
- });
2321
- armLateAnswerTelemetry({
2322
- requestPromise: workspaceRequestPromise,
2323
- scope: WORKSPACE_PULL_SCOPE,
2324
- identifier: undefined,
2325
- subject: WORKSPACE_PULL_SCOPE,
2326
- requestStartedAt: workspacePullStartedAt,
2327
- server: state.serverId,
2328
- settlement: workspacePullSettlement,
2329
- });
2330
- }
2331
- recordPullFailure(state, "workspace/diagnostic", err);
2332
- return undefined;
2333
- }
2334
- }
2335
- export async function clientWaitForDiagnostics(state, filePath, timeoutMs, options = {}) {
2336
- const normalizedPath = normalizeMapKey(filePath);
2337
- const minVersion = options.minVersion;
2338
- const pullSettleSource = options.pullSettleSource ?? "pull";
2339
- // #1531: the freshness gate is PER PATH. `minVersion` is a reading of the
2340
- // client-global counter, and `diagnosticsVersionsByPath` stores that same
2341
- // counter's value at each store — the two are on one axis, so comparing them
2342
- // asks "has a publication landed for THIS file since the baseline?" instead of
2343
- // "has anything at all landed on this client?". Reading the global counter here
2344
- // let a sibling file's publication end this file's wait before its own budget
2345
- // lapsed, which then labelled the outcome row `silent` (reserved for "own
2346
- // budget lapsed with nothing published") instead of `cut_off`.
2347
- const hasFreshDiagnostics = () => minVersion === undefined ||
2348
- diagnosticsVersionForPath(state, normalizedPath) > minVersion;
2349
- // Version coherence: a cached push is "stale" only when the server reported
2350
- // the document version it computed against AND that version lags the latest
2351
- // didChange we sent. This prevents serving diagnostics from a superseded
2352
- // version as fresh (e.g. once the redundant double-push is collapsed and the
2353
- // dispatch wait runs without a push-counter baseline — #203). Unknown version
2354
- // (server omits it) is treated as current so version-less servers are
2355
- // unaffected, and the timeout remains the backstop.
2356
- const isVersionStale = () => {
2357
- const cachedVersion = state.diagnosticDocVersions?.get(normalizedPath);
2358
- if (cachedVersion === undefined)
2359
- return false;
2360
- const currentVersion = state.documentVersions?.get(normalizedPath);
2361
- return currentVersion !== undefined && cachedVersion < currentVersion;
2362
- };
2363
- if (state.workspaceDiagnosticsSupport.mode === "pull") {
2364
- // Pull is authoritative. An AFFIRMATIVE outcome — diagnostics `found`, or
2365
- // an authoritative empty `clean` report — ends the wait. An `unavailable`
2366
- // pull (dead client / no reply / thrown) is NOT clean and must not
2367
- // short-circuit: fall through to the push-wait/timeout backstop. This is
2368
- // the #240 fix — previously the early-return also fired on
2369
- // `hasFreshDiagnostics()`, which is unconditionally true when there is no
2370
- // version baseline (`minVersion === undefined`), so a failed pull returned
2371
- // 0 and was read as a fresh clean.
2372
- // #1639: the settle operation's OWN clock — durationMs on the eventual
2373
- // log record measures from here, never from didOpen (that stays in
2374
- // metadata.elapsedSinceDidOpenMs, computed separately below).
2375
- const pullSettleStartedAt = Date.now();
2376
- let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
2377
- if (outcome.status === "found") {
2378
- logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
2379
- return;
2380
- }
2381
- let sawClean = outcome.status === "clean";
2382
- const strategy = getStrategy(state.serverId, state.launchVariant);
2383
- const retryBudgetMs = strategy.pullRetryBudgetMs > 0
2384
- ? Math.min(timeoutMs, strategy.pullRetryBudgetMs)
2385
- : 0;
2386
- const startedAt = Date.now();
2387
- // Retry within budget to catch incremental servers whose first pull is
2388
- // empty while analysis is still running (rust-analyzer). A `clean` seen at
2389
- // any point is a valid affirmative answer for this touch.
2390
- while (outcome.status !== "found" &&
2391
- Date.now() - startedAt < retryBudgetMs) {
2392
- await new Promise((resolve) => setTimeout(resolve, PULL_DIAGNOSTICS_RETRY_INTERVAL_MS));
2393
- outcome = await clientRequestPullDiagnostics(state, filePath, Math.max(0, retryBudgetMs - (Date.now() - startedAt)));
2394
- if (outcome.status === "clean")
2395
- sawClean = true;
2396
- }
2397
- if (options.pullOnly) {
2398
- if (outcome.status === "found" || sawClean) {
2399
- logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
2400
- }
2401
- return;
2402
- }
2403
- if (outcome.status === "found" || sawClean) {
2404
- logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
2405
- return;
2406
- }
2407
- }
2408
- if (hasFreshDiagnostics() &&
2409
- !isVersionStale() &&
2410
- getMergedDiagnosticsForPath(state, normalizedPath).length > 0) {
2411
- return;
2412
- }
2413
- return new Promise((resolve) => {
2414
- let debounceTimer;
2415
- const onDiagnostics = (fp) => {
2416
- if (normalizeMapKey(fp) !== normalizedPath)
2417
- return;
2418
- if (!hasFreshDiagnostics() || isVersionStale())
2419
- return;
2420
- if (debounceTimer)
2421
- clearTimeout(debounceTimer);
2422
- // Adaptive debounce: use time since last push to compute remaining
2423
- // wait instead of always waiting the full debounce window.
2424
- const strategy = getStrategy(state.serverId, state.launchVariant);
2425
- const hit = state.pushDiagnosticTimestamps.get(normalizedPath);
2426
- const timeSincePush = hit ? Date.now() - hit : Infinity;
2427
- const remaining = Math.max(0, strategy.debounceMs - timeSincePush);
2428
- debounceTimer = setTimeout(() => {
2429
- state.diagnosticEmitter.off("diagnostics", onDiagnostics);
2430
- clearTimeout(timeout);
2431
- resolve();
2432
- }, remaining);
2433
- };
2434
- state.diagnosticEmitter.on("diagnostics", onDiagnostics);
2435
- const timeout = setTimeout(() => {
2436
- if (debounceTimer)
2437
- clearTimeout(debounceTimer);
2438
- state.diagnosticEmitter.off("diagnostics", onDiagnostics);
2439
- resolve();
2440
- }, timeoutMs);
2441
- });
2442
- }
2443
- /**
2444
- * Queue a watched-files change for a file this client did not learn about
2445
- * through textDocument/didOpen or didChange — an external bash write/delete,
2446
- * or any other disk change outside the open-document sync path (#1668).
2447
- * Shares `handleNotifyOpen`'s per-client debounced queue (#271), so a burst
2448
- * of external changes coalesces into one notification per debounce window
2449
- * instead of flooding the server with one per file.
2450
- */
2451
- export function handleNotifyExternalChange(state, filePath, type) {
2452
- if (!isClientAlive(state))
2453
- return;
2454
- const normalizedPath = normalizeMapKey(filePath);
2455
- const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2456
- state.watchQueue.enqueue(uri, type);
2457
- }
2458
- async function handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics = false, silent = false, coalescedCount = 0) {
2459
- if (!isClientAlive(state))
2460
- return;
2461
- const normalizedPath = normalizeMapKey(filePath);
2462
- const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2463
- if (state.openDocuments.has(normalizedPath) ||
2464
- state.pendingOpens.has(normalizedPath)) {
2465
- const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1;
2466
- state.documentVersions.set(normalizedPath, version);
2467
- // preserveDiagnostics: skip cache clear for format-only resyncs so
2468
- // waitForDiagnostics fast-paths instead of waiting up to 5s for TypeScript
2469
- // to re-publish what it already knows (formatting doesn't change semantics).
2470
- // #1095 note: this also retains the prior content `binding`. Until the
2471
- // server republishes for the new version, a binding read compares the OLD
2472
- // content hash against the NEW disk bytes → boundToCurrentDisk `false` →
2473
- // the consumer demotes to inconclusive. That's the SAFE (#533) direction (a
2474
- // transient "unconfirmed", never a false-clean), self-healing on the next
2475
- // publish — not a correctness hazard, unlike the reopen false-TRUE above.
2476
- if (!preserveDiagnostics) {
2477
- clearDiagnosticsForPath(state, normalizedPath);
2478
- }
2479
- // Scanners that only re-scan on a fresh open (opengrep ignores didChange):
2480
- // close + reopen so the re-edit actually triggers a re-scan instead of
2481
- // silently publishing nothing.
2482
- if (getStrategy(state.serverId, state.launchVariant).reopenOnResync) {
2483
- await safeSendNotification(state.connection, "textDocument/didClose", {
2484
- textDocument: { uri },
2485
- });
2486
- state.openDocuments.delete(normalizedPath);
2487
- state.openDocumentUris?.delete(normalizedPath);
2488
- // #1095 (P2-3): carry the version counter FORWARD across the
2489
- // close+reopen instead of resetting to 0. LSP lets a didOpen use any
2490
- // version, and reusing 0 for successive resyncs made the version
2491
- // ambiguous — a late publish for an earlier resync's content echoed the
2492
- // SAME 0 as the current send, so the superseded-push guard (0 < 0 is
2493
- // false) accepted it and `recordBinding` bound STALE diagnostics to the
2494
- // CURRENT content's fingerprint → an affirmative boundToCurrentDisk TRUE
2495
- // for a stale view (worse than "unknown"). Monotonic versions make that
2496
- // late echo strictly older → dropped by isSupersededPush → never bound.
2497
- state.documentVersions.set(normalizedPath, version);
2498
- state.documentOpenedAt.set(normalizedPath, Date.now());
2499
- state.diagnosticPublicationCounts.set(normalizedPath, 0);
2500
- if (!isClientAlive(state))
2501
- return;
2502
- const reopenSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version, text: content } });
2503
- // #1669 review F7: only mirror the send locally once it actually left
2504
- // the process — see safeSendNotification's doc comment.
2505
- if (reopenSent)
2506
- recordSentContent(state, normalizedPath, version, content, coalescedCount);
2507
- state.openDocuments.add(normalizedPath);
2508
- state.openDocumentUris?.set(normalizedPath, uri);
2509
- return;
2510
- }
2511
- const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
2512
- textDocument: { uri, version },
2513
- contentChanges: buildContentChanges(state, normalizedPath, content),
2514
- });
2515
- if (changeSent)
2516
- recordSentContent(state, normalizedPath, version, content, coalescedCount);
2517
- return;
2518
- }
2519
- state.pendingOpens.add(normalizedPath);
2520
- state.documentVersions.set(normalizedPath, 0);
2521
- state.documentOpenedAt.set(normalizedPath, Date.now());
2522
- state.diagnosticPublicationCounts.set(normalizedPath, 0);
2523
- clearDiagnosticsForPath(state, normalizedPath); // always clear for initial open
2524
- // Send workspace notification first (like opencode does).
2525
- // Skipped in silent mode — cascade reads a file for diagnostics,
2526
- // not reporting a real filesystem change. Avoids N project-wide
2527
- // rechecks on push-diagnostics LSPs (TypeScript, Python) per CR-1.
2528
- if (!silent) {
2529
- // Async existence probe (was a synchronous existsSync on the document-open
2530
- // path — a stat that blocks the loop during first-read/warm). The notify
2531
- // type is unchanged: 2 (Changed) when the file exists on disk, else 1
2532
- // (Created). access() rejects when absent.
2533
- let fileExists = true;
2534
- try {
2535
- await access(filePath);
2536
- }
2537
- catch {
2538
- fileExists = false;
2539
- }
2540
- // #271: enqueue instead of sending now — the per-client queue coalesces a
2541
- // turn's file opens into a single notification, so push-diagnostics servers
2542
- // re-analyze the project once per burst rather than once per file. didOpen
2543
- // (below) still carries this file's content immediately, so the open
2544
- // document is analyzed without waiting on the batched watcher notify.
2545
- state.watchQueue.enqueue(uri, fileExists ? 2 : 1);
2546
- }
2547
- if (!isClientAlive(state))
2548
- return;
2549
- const openSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version: 0, text: content } });
2550
- if (openSent)
2551
- recordSentContent(state, normalizedPath, 0, content, coalescedCount);
2552
- state.pendingOpens.delete(normalizedPath);
2553
- state.openDocuments.add(normalizedPath);
2554
- state.closedDocuments?.delete(normalizedPath);
2555
- state.openDocumentUris?.set(normalizedPath, uri);
2556
- // Telemetry is deliberately detached after didOpen succeeds.
2557
- // #1412 H1: routed through runReadOnlyServerCommand, NOT runServerCommand —
2558
- // the probe must never open the serverEditsAllowed/activeMutationContext
2559
- // mutation-acceptance window; it is a diagnostic sample, not a mutation, and
2560
- // carries its own short PROBE_COMMAND_TIMEOUT_MS backstop. The probe itself
2561
- // is classic-only and swallows every failure.
2562
- void probeTsserverProjectIdentity({
2563
- serverId: state.serverId,
2564
- launchVariant: state.launchVariant,
2565
- clientRoot: state.root,
2566
- file: filePath,
2567
- normalizedFile: normalizedPath,
2568
- probedFiles: state.projectIdentityProbedFiles ??
2569
- (state.projectIdentityProbedFiles = new Set()),
2570
- commandChannel: {
2571
- executeCommand: (command, args) => runReadOnlyServerCommand(state, command, args),
2572
- },
2573
- });
2574
- }
2575
- /**
2576
- * Replace only notifications that have not started a transport write. A
2577
- * microtask starts the first entry so a synchronous burst of callers installs
2578
- * one latest entry before any bytes are handed to vscode-jsonrpc. Once an
2579
- * entry starts, it runs to completion; LSP has no cancellation/acknowledgement
2580
- * for notifications, so dropping a started write could violate didOpen before
2581
- * didChange ordering or leave a partial message in the pipe.
2582
- */
2583
- function enqueueDocumentNotify(state, normalizedPath, run) {
2584
- let queue = state.notifyChangeQueues.get(normalizedPath);
2585
- if (!queue) {
2586
- queue = { running: false };
2587
- state.notifyChangeQueues.set(normalizedPath, queue);
2588
- }
2589
- return new Promise((resolve, reject) => {
2590
- const previous = queue?.pending;
2591
- // Keep superseded callers attached to the replacement's completion. The
2592
- // notification is dropped, but callers such as the auxiliary backlog
2593
- // ledger must not observe completion before the newest content is sent.
2594
- const waiters = previous?.waiters ?? [];
2595
- waiters.push({ resolve, reject });
2596
- queue.pending = {
2597
- run,
2598
- waiters,
2599
- coalescedCount: (previous?.coalescedCount ?? 0) + (previous ? 1 : 0),
2600
- };
2601
- if (queue.running)
2602
- return;
2603
- queue.running = true;
2604
- // Let same-turn callers replace the unwritten entry before the runner
2605
- // invokes the transport. Different path queues still start independently.
2606
- void Promise.resolve().then(async () => {
2607
- try {
2608
- for (;;) {
2609
- const next = queue.pending;
2610
- if (!next)
2611
- break;
2612
- queue.pending = undefined;
2613
- try {
2614
- await next.run(next.coalescedCount);
2615
- for (const waiter of next.waiters)
2616
- waiter.resolve();
2617
- }
2618
- catch (error) {
2619
- for (const waiter of next.waiters)
2620
- waiter.reject(error);
2621
- }
2622
- }
2623
- }
2624
- finally {
2625
- queue.running = false;
2626
- if (!queue.pending &&
2627
- state.notifyChangeQueues.get(normalizedPath) === queue) {
2628
- state.notifyChangeQueues.delete(normalizedPath);
2629
- }
2630
- }
2631
- });
2632
- });
2633
- }
2634
- /** Drop unwritten document notifications when a client is torn down. */
2635
- function cancelDocumentNotifyQueues(state) {
2636
- for (const queue of state.notifyChangeQueues.values()) {
2637
- for (const waiter of queue.pending?.waiters ?? [])
2638
- waiter.resolve();
2639
- queue.pending = undefined;
2640
- }
2641
- state.notifyChangeQueues.clear();
2642
- }
2643
- export function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
2644
- if (!isClientAlive(state))
2645
- return Promise.resolve();
2646
- const normalizedPath = normalizeMapKey(filePath);
2647
- return enqueueDocumentNotify(state, normalizedPath, (coalescedCount) => handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics, silent, coalescedCount));
2648
- }
2649
- async function handleNotifyChangeOnce(state, filePath, content, normalizedPath, coalescedCount = 0) {
2650
- if (!isClientAlive(state))
2651
- return;
2652
- const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2653
- if (!state.openDocuments.has(normalizedPath)) {
2654
- // Safety fallback: keep protocol ordering valid even if caller sends
2655
- // didChange before first didOpen for this document.
2656
- const fallbackOpenSent = await safeSendNotification(state.connection, "textDocument/didOpen", {
2657
- textDocument: {
2658
- uri,
2659
- languageId: "plaintext",
2660
- version: 0,
2661
- text: content,
2662
- },
2663
- });
2664
- state.documentVersions.set(normalizedPath, 0);
2665
- state.documentOpenedAt.set(normalizedPath, Date.now());
2666
- state.diagnosticPublicationCounts.set(normalizedPath, 0);
2667
- if (fallbackOpenSent)
2668
- recordSentContent(state, normalizedPath, 0, content, coalescedCount);
2669
- state.openDocuments.add(normalizedPath);
2670
- state.openDocumentUris?.set(normalizedPath, uri);
2671
- return;
2672
- }
2673
- const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1;
2674
- state.documentVersions.set(normalizedPath, version);
2675
- // Clear stale diagnostics before sending new content so waitForDiagnostics
2676
- // doesn't return immediately with the previous edit's results.
2677
- clearDiagnosticsForPath(state, normalizedPath);
2678
- const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
2679
- textDocument: { uri, version },
2680
- contentChanges: buildContentChanges(state, normalizedPath, content),
2681
- });
2682
- if (changeSent)
2683
- recordSentContent(state, normalizedPath, version, content, coalescedCount);
2684
- }
2685
- /**
2686
- * #2113: serialize the read/build/send/record transaction per document. The
2687
- * content-change payload must be built against the content recorded by the
2688
- * preceding send for this path; unrelated paths retain parallel sends.
2689
- */
2690
- export function handleNotifyChange(state, filePath, content) {
2691
- if (!isClientAlive(state))
2692
- return Promise.resolve();
2693
- const normalizedPath = normalizeMapKey(filePath);
2694
- return enqueueDocumentNotify(state, normalizedPath, (coalescedCount) => handleNotifyChangeOnce(state, filePath, content, normalizedPath, coalescedCount));
2695
- }
2696
- /** Close a document through the same lifecycle path exposed by the client. */
2697
- export async function closeDocument(state, filePath) {
2698
- if (!isClientAlive(state))
2699
- return;
2700
- const normalizedPath = normalizeMapKey(filePath);
2701
- if (!state.openDocuments.has(normalizedPath))
2702
- return;
2703
- await safeSendNotification(state.connection, "textDocument/didClose", {
2704
- textDocument: {
2705
- uri: state.openDocumentUris?.get(normalizedPath) ??
2706
- pathToFileURL(filePath).href,
2707
- },
2708
- });
2709
- state.openDocuments.delete(normalizedPath);
2710
- state.closedDocuments?.add(normalizedPath);
2711
- state.openDocumentUris?.delete(normalizedPath);
2712
- state.documentVersions.delete(normalizedPath);
2713
- state.documentOpenedAt.delete(normalizedPath);
2714
- state.diagnosticPublicationCounts.delete(normalizedPath);
2715
- // #1412 L1: projectIdentityProbedFiles is a claim-once memo scoped to the
2716
- // document's open lifetime (re-probing a closed-then-reopened file is
2717
- // harmless and cheap) — mirror openDocuments' own per-close cleanup so it
2718
- // doesn't grow unbounded across a long session's worth of open/close churn.
2719
- state.projectIdentityProbedFiles?.delete(normalizedPath);
2720
- const retained = state.documentContentHashes.get(normalizedPath);
2721
- if (retained?.text !== undefined) {
2722
- state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
2723
- state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? retained.text.length * 2) -
2724
- retained.text.length * 2);
2725
- // #2065 fix round 2 N1: `recordSentContent` and the eviction loop both
2726
- // delete from `incrementalTextBearingPaths` when they strip a path's
2727
- // text, but neither runs on close — this was the missing third writer.
2728
- // Without it, a closed path's membership in the aux set survives
2729
- // forever: below the 128-entry/64 MiB cap the eviction loop that would
2730
- // otherwise clean it up never runs at all, so the set grows by one
2731
- // stale entry per open/close cycle with nothing ever draining it — the
2732
- // exact unbounded-per-path-store class #2065 exists to close, just
2733
- // moved into the aux index instead of the byte-retention count itself.
2734
- state.incrementalTextBearingPaths?.delete(normalizedPath);
2735
- }
2736
- // Keep the hash/version binding only while the document is open; the full
2737
- // text is needed for the next edit, never after didClose (#2065).
2738
- state.documentContentHashes.delete(normalizedPath);
2739
- clearDiagnosticsForPath(state, normalizedPath);
2740
- // #2065 fix round 2 (reverts fix round 1 F4): `pullGenerations` is the
2741
- // eighth per-path map in this family, but it is deliberately NOT cleared
2742
- // here — the same reasoning `clearDiagnosticsForPath` already documents
2743
- // above for `pullRequestSequences` applies to this counter too, and fix
2744
- // round 1's F4 missed it. `pullGenerationFor` defaults a missing entry to
2745
- // `0`, so deleting the entry on close resets the counter. A pull already
2746
- // in flight before the close captured its generation BEFORE this close's
2747
- // `clearDiagnosticsForPath` bump above ran. If the document is closed and
2748
- // reopened before that in-flight pull's write lands, deleting the entry
2749
- // here would let the counter read back as `0` again — the exact value the
2750
- // stale write's stale captured generation equals — so the write-time
2751
- // `!== generation` check at its call site would wrongly accept a stale
2752
- // answer computed against the pre-close content. Leaving the counter
2753
- // monotonic and un-reset keeps every pre-close capture permanently
2754
- // distinguishable from whatever the counter reads afterward, no matter how
2755
- // many closes or reopens happen in between. The cost is one integer per
2756
- // path that outlives the document's open lifetime — negligible next to
2757
- // correctness here.
2758
- }
2759
- /**
2760
- * #1620 residual review F1: `fast` and `processExiting` only ever escalate —
2761
- * a `true` demands strictly less work (or none) than a `false`, never more.
2762
- * `existing` covers `requested` when it is at least as demanding on BOTH
2763
- * axes, so deduping onto it can't silently downgrade what the new caller
2764
- * needs (e.g. a `processExiting` caller must never inherit a teardown that
2765
- * might still spawn `taskkill`).
2766
- */
2767
- function isAtLeastAsAggressiveShutdown(existing, requested) {
2768
- return (!!existing.fast >= !!requested.fast &&
2769
- !!existing.processExiting >= !!requested.processExiting);
2770
- }
2771
- export function clientShutdown(state, options = {}) {
2772
- // #1620 residual 3: idempotent across racing callers — see the doc comment
2773
- // on `LSPClientState.shutdownPromise`. The FIRST call starts the real
2774
- // teardown and stores its promise; every subsequent call (any of the 8
2775
- // call sites, on the same state) whose options are no MORE aggressive
2776
- // awaits that same promise instead of running the RPC handshake and
2777
- // emitting `lsp_client_shutdown` again.
2778
- //
2779
- // #1620 residual review F1: a call whose options ARE more aggressive does
2780
- // NOT dedupe — it starts (and takes over as) its own teardown instead.
2781
- // The concrete failure this closes: `resetLSPService`'s session-exit path
2782
- // (`index.ts`) calls `shutdown({fast:true, processExiting:true})` — the
2783
- // event loop is already closing, and `processExiting` exists precisely so
2784
- // this teardown never spawns `taskkill` (a closing-loop `uv_async_send`
2785
- // hard-aborts, `src\win\async.c`). If a GRACEFUL teardown (default
2786
- // options — e.g. `client_ceiling_lru` eviction) is already in flight on
2787
- // this same client when the exit path arrives, blindly deduping would
2788
- // make the exit path inherit that graceful run's own `taskkill` spawn and
2789
- // its close-event wait — the exact hazard `processExiting` forbids, on
2790
- // top of blocking the closing loop for however long that takes. The
2791
- // weaker in-flight teardown is left to finish on its own (every step
2792
- // downstream — dispose, kill, deregister — is already idempotent).
2793
- if (state.shutdownPromise &&
2794
- state.shutdownOptions &&
2795
- isAtLeastAsAggressiveShutdown(state.shutdownOptions, options)) {
2796
- return state.shutdownPromise;
2797
- }
2798
- state.shutdownOptions = options;
2799
- const attempt = clientShutdownOnce(state, options);
2800
- state.shutdownPromise = attempt;
2801
- // #1620 residual review F3: a REJECTED teardown (e.g. `killProcessTree`
2802
- // throwing something its own internal catches don't cover) must not
2803
- // latch a permanently-rejected promise here — every later call, even one
2804
- // with equal or weaker options, would dedupe onto it forever and never
2805
- // retry, so the child leaks silently with no further kill/dispose/
2806
- // deregister attempt. Clear the latch on rejection (only if nothing more
2807
- // aggressive has already superseded it) so the NEXT call starts a fresh
2808
- // attempt instead of re-handing back the same dead promise. The rejection
2809
- // itself still propagates to whoever is awaiting `attempt` right now —
2810
- // this only affects callers that haven't asked yet.
2811
- attempt.catch(() => {
2812
- if (state.shutdownPromise === attempt) {
2813
- state.shutdownPromise = undefined;
2814
- state.shutdownOptions = undefined;
2815
- }
2816
- });
2817
- return attempt;
2818
- }
2819
- async function clientShutdownOnce(state, options) {
2820
- const shutdownStart = Date.now();
2821
- state.shutdownRequested = true;
2822
- state.isConnected = false;
2823
- state.isDestroyed = true;
2824
- for (const timer of state.pendingDiagnostics.values()) {
2825
- clearTimeout(timer);
2826
- }
2827
- state.pendingDiagnostics.clear();
2828
- state.pendingOpens.clear();
2829
- state.openDocuments.clear();
2830
- state.openDocumentUris?.clear();
2831
- // #2357: superseded notifications that have not started writing are moot
2832
- // once this client is dead; resolve their callers and release the queue.
2833
- cancelDocumentNotifyQueues(state);
2834
- // #1412 L1: mirror openDocuments' clear — a shut-down/evicted client's
2835
- // probe memo is moot along with everything else document-scoped.
2836
- state.projectIdentityProbedFiles?.clear();
2837
- // #271: drop any pending watched-files batch + its timer (a dying client's
2838
- // queued FS changes are moot, and the timer must not outlive the connection).
2839
- state.watchQueue?.cancel();
2840
- state.diagnosticEmitter.removeAllListeners();
2841
- let shutdownRequestTimedOut = false;
2842
- let exitNotifyTimedOut = false;
2843
- // #1620 residual 1 / residual-review F2: a failure that ISN'T the timer
2844
- // winning was previously folded into the same "*TimedOut" flag as a real
2845
- // timeout — honest for the rolled-up `shutdownOutcome` (still "forced"
2846
- // either way), imprecise about WHICH failure happened. Two shapes land
2847
- // here, both meaning "no confirmation this reached the server":
2848
- // (a) a genuine promise rejection that isn't the timer (an immediate
2849
- // protocol error) — reaches the `catch` below.
2850
- // (b) `safeSendRequest`/`safeSendNotification` SWALLOW a stream error
2851
- // (EPIPE, disposed connection — `isStreamError`) and RESOLVE
2852
- // instead of rejecting, so the `catch` never runs at all. A real
2853
- // `shutdown` reply is `null`, never `undefined`, and a real `exit`
2854
- // notify send returns `true`; `undefined`/`false` is the swallow
2855
- // case. Without this, a coded EPIPE reported `shutdownOutcome:
2856
- // "graceful"` — nothing was delivered, but the log called it clean.
2857
- let shutdownRequestUndelivered = false;
2858
- let exitNotifyUndelivered = false;
2859
- // #1620: the graceful handshake is BEST-EFFORT and the teardown is not. A
2860
- // teardown path must not depend on the health of the thing it is tearing
2861
- // down — the breaker fires exactly when the server is unresponsive. Keep the
2862
- // handshake inside the `try` and every irreversible teardown step in the
2863
- // `finally`, so disposal, the record, the deregistration, and the kill run no
2864
- // matter how the two writes behave, and so a future added await here cannot
2865
- // reintroduce the class.
2866
- try {
2867
- if (!options.fast) {
2868
- try {
2869
- const shutdownAck = await withTimeout(safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS);
2870
- if (shutdownAck === undefined)
2871
- shutdownRequestUndelivered = true;
2872
- }
2873
- catch (err) {
2874
- /* ignore — proceed to exit/kill so shutdown cannot hang the session */
2875
- if (isShutdownTimeoutError(err, SHUTDOWN_REQUEST_TIMEOUT_MS)) {
2876
- shutdownRequestTimedOut = true;
2877
- }
2878
- else {
2879
- shutdownRequestUndelivered = true;
2880
- }
2881
- }
2882
- try {
2883
- const exitSent = await withTimeout(safeSendNotification(state.connection, "exit", {}), EXIT_NOTIFY_TIMEOUT_MS);
2884
- if (exitSent === false)
2885
- exitNotifyUndelivered = true;
2886
- }
2887
- catch (err) {
2888
- /* ignore — same reason as the request above */
2889
- if (isShutdownTimeoutError(err, EXIT_NOTIFY_TIMEOUT_MS)) {
2890
- exitNotifyTimedOut = true;
2891
- }
2892
- else {
2893
- exitNotifyUndelivered = true;
2894
- }
2895
- }
2896
- }
2897
- }
2898
- finally {
2899
- // #2065 fix round 1: deregistration now lives in disposeClientConnection
2900
- // itself (the convergence point for every crash/dispose path, not just
2901
- // this graceful one) — see its comment.
2902
- disposeClientConnection(state);
2903
- const pid = state.lspProcess.pid;
2904
- logLatency({
2905
- type: "phase",
2906
- phase: "lsp_client_shutdown",
2907
- filePath: state.root,
2908
- durationMs: Date.now() - shutdownStart,
2909
- metadata: {
2910
- serverId: state.serverId,
2911
- pid,
2912
- fast: !!options.fast,
2913
- processExiting: !!options.processExiting,
2914
- shutdownRequestTimedOut,
2915
- // #1620: `shutdownRequestTimedOut` covered only the request half, so
2916
- // a hung exit notify was indistinguishable from a clean exit — and
2917
- // pre-fix no record was emitted at all. Report both halves plus one
2918
- // rolled-up verdict, so a forced teardown is countable from the log.
2919
- exitNotifyTimedOut,
2920
- // #1620 residual 1 / residual-review F2: the *Undelivered pair covers
2921
- // the failure the *TimedOut pair used to also claim — a rejection
2922
- // that is NOT the timer winning, OR (the F2 gap) a swallowed stream
2923
- // error that `safeSendRequest`/`safeSendNotification` resolved
2924
- // instead of rejecting, so no exception ever reached the catches
2925
- // above. Both still roll into shutdownOutcome "forced" below — an
2926
- // undelivered write is never reported as "graceful".
2927
- shutdownRequestUndelivered,
2928
- exitNotifyUndelivered,
2929
- shutdownOutcome: options.fast
2930
- ? "fast"
2931
- : shutdownRequestTimedOut ||
2932
- exitNotifyTimedOut ||
2933
- shutdownRequestUndelivered ||
2934
- exitNotifyUndelivered
2935
- ? "forced"
2936
- : "graceful",
2937
- },
2938
- });
2939
- // #449/#472: deregister this LSP child from the instance registry. Fire-
2940
- // and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
2941
- // including the `processExiting` path where the event loop is closing
2942
- // (#234 forbids spawning here, but a plain fs write/rename is fine; even
2943
- // so, we don't await it to keep this teardown path as fast as before).
2944
- // #1724: pass this spawn's marker so removeLspChild can guard against a
2945
- // pid-recycling window (a NEW child claiming this exact pid before this
2946
- // fire-and-forget write lands) rather than removing whichever child
2947
- // currently sits at this pid.
2948
- void removeLspChild(pid, extractSpawnMarker(state.lspProcess.args)).catch((err) => {
2949
- logLatency({
2950
- type: "phase",
2951
- phase: "lsp_registry_write_failed",
2952
- filePath: "",
2953
- durationMs: 0,
2954
- metadata: { op: "remove", pid, error: String(err) },
2955
- });
2956
- });
2957
- // On Windows, killing the direct child first can orphan grandchildren before
2958
- // taskkill can traverse the tree. Kill the full tree first and wait briefly.
2959
- // Safe and idempotent on an already-exited child: killProcessTree returns
2960
- // early on an observed exit rather than signalling a possibly-recycled pid.
2961
- await killProcessTree(state.lspProcess.process, pid, options);
2962
- }
2963
- }
2964
- /**
2965
- * Translate a caller-supplied (UTF-16) `(line, character)` into the position the
2966
- * server expects under its negotiated encoding (#269). UTF-16 is the identity —
2967
- * the common case pays nothing (no I/O). For UTF-8/UTF-32 we read the target
2968
- * line from disk (pi edits files on disk before navigating, so disk == the
2969
- * server's content) and re-measure the character offset; a read failure falls
2970
- * back to the raw offset rather than dropping the request.
2971
- */
2972
- async function toWirePosition(state, filePath, line, character) {
2973
- if (state.positionEncoding === "utf-16")
2974
- return { line, character };
2975
- try {
2976
- const content = await readFile(filePath, "utf8");
2977
- return {
2978
- line,
2979
- character: convertCharacterOffset(state.positionEncoding, lineTextAt(content, line), character),
2980
- };
2981
- }
2982
- catch {
2983
- return { line, character };
2984
- }
2985
- }
2986
- // #276: drop a navigation result whose document was edited while the request was
2987
- // in flight. Mirrors the diagnostics-path staleness check (isVersionStale) which
2988
- // compares the version computed-against to the latest didChange. Default on;
2989
- // PI_LENS_LSP_NAV_STALE_DROP=0 disables it if it ever over-drops.
2990
- function navStaleDropEnabled() {
2991
- return process.env.PI_LENS_LSP_NAV_STALE_DROP !== "0";
2992
- }
2993
- // Exported for the timeout regression tests (#365). `timeoutMs` overrides the
2994
- // per-request ceiling so a test can bound a hung server quickly.
2995
- export async function navRequest(state, method, params,
2996
- // When provided, the request is dropped if the document's version advances
2997
- // (an edit landed) between send and response. Omit for non-single-file
2998
- // requests (workspaceSymbol, call-hierarchy follow-ups) that have no version.
2999
- staleCheckPath, timeoutMs = NAV_REQUEST_TIMEOUT_MS,
3000
- // Cancels the in-flight request (LSP `$/cancelRequest`) when the turn is
3001
- // abandoned. Defaults to the ambient abort signal set around dispatch/tool
3002
- // handling, so callers get cancellation for free without a signature change
3003
- // (#238 Item 1). Pass explicitly in tests.
3004
- signal = getAmbientAbortSignal()) {
3005
- if (!isClientAlive(state))
3006
- return null;
3007
- const normalizedPath = staleCheckPath !== undefined ? normalizeMapKey(staleCheckPath) : undefined;
3008
- const requestVersion = normalizedPath !== undefined
3009
- ? state.documentVersions.get(normalizedPath)
3010
- : undefined;
3011
- // #1716: captured OUTSIDE withTimeout, mirroring #1713's pull-diagnostic
3012
- // fix (`pullDiagnosticSource`), so a timeout's catch below still holds a
3013
- // live handle on the request for the late-answer watch. `withTimeout`
3014
- // only races it against a timer — the request keeps running server-side
3015
- // either way, and without this handle an abandoned answer would settle
3016
- // into the void with no way to observe it.
3017
- const requestStartedAt = Date.now();
3018
- const requestPromise = safeSendRequest(state.connection, method, params, signal);
3019
- const result = (await withTimeout(requestPromise, timeoutMs).catch((err) => {
3020
- if (isNavTimeoutError(err, timeoutMs)) {
3021
- recordNavTimeoutTelemetry({
3022
- method,
3023
- scope: normalizedPath,
3024
- budgetMs: timeoutMs,
3025
- elapsedMs: Date.now() - requestStartedAt,
3026
- });
3027
- armNavLateAnswerTelemetry({
3028
- requestPromise,
3029
- method,
3030
- scope: normalizedPath,
3031
- requestStartedAt,
3032
- });
3033
- return undefined;
3034
- }
3035
- if (err instanceof Error && err.message.startsWith("Timeout after")) {
3036
- return undefined;
3037
- }
3038
- throw err;
3039
- }));
3040
- // requestVersion === undefined (never opened, or version-less) → unaffected,
3041
- // matching the diagnostics path; the request timeout remains the backstop.
3042
- if (normalizedPath !== undefined &&
3043
- requestVersion !== undefined &&
3044
- navStaleDropEnabled()) {
3045
- const currentVersion = state.documentVersions.get(normalizedPath);
3046
- if (currentVersion !== undefined && currentVersion > requestVersion) {
3047
- return undefined;
3048
- }
3049
- }
3050
- return result;
3051
- }
3052
- /**
3053
- * #1716: `withTimeout` rejects with this EXACT message (`clients/deadline-
3054
- * utils.ts`) only when its own timer wins the race — mirrors #1713's
3055
- * `isPullTimeoutError`. Only the timer-won case is a "the answer might still
3056
- * be coming" situation worth a late-answer watch; a request that already
3057
- * failed fast (or a stale-message coincidence) has nothing further to
3058
- * observe. `navRequest`'s own behavior still falls back to the looser
3059
- * `startsWith` check afterward, unchanged, so this stricter check only gates
3060
- * telemetry — never the existing timeout-handling behavior.
3061
- */
3062
- function isNavTimeoutError(err, timeoutMs) {
3063
- return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
3064
- }
3065
- /**
3066
- * #1620 residual 1: `withTimeout` rejects with this EXACT message only when
3067
- * its own timer wins the race — mirrors #1713's `isPullTimeoutError` and
3068
- * #1716's `isNavTimeoutError`. `clientShutdown`'s two catches previously
3069
- * treated ANY rejection (a real timeout OR an immediate protocol error) as
3070
- * "timed out"; honest for the rolled-up `shutdownOutcome` (both are
3071
- * "forced"), imprecise for the per-field flag a log reader keys on.
3072
- */
3073
- function isShutdownTimeoutError(err, timeoutMs) {
3074
- return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
3075
- }
3076
- // #1716: subject/scope used when a nav request has no `staleCheckPath` (e.g.
3077
- // workspaceSymbol, call-hierarchy follow-ups) — mirrors WORKSPACE_PULL_SCOPE
3078
- // above for the same reason: telemetry identity needs a stable placeholder
3079
- // where there is no real path.
3080
- const NAV_REQUEST_NO_PATH_SCOPE = "*no-path*";
3081
- /**
3082
- * #1716: `navRequest` is the highest-volume LSP call site — hover,
3083
- * definition, references, signatureHelp, documentSymbol, workspace/symbol,
3084
- * call hierarchy all route through it, often several times per turn. A stuck
3085
- * server can storm timeouts across many methods and files far faster than
3086
- * the pull-diagnostic path #1713 fixed, so a per-event latency.log write
3087
- * (that fix's shape) would itself become the flood. Every timeout is still
3088
- * counted exactly, via the degradation ledger (bounded, in-memory, no I/O),
3089
- * but only the RISING EDGE — the first occurrence per (method, file) this
3090
- * session — also pays for a detailed log write. `incrementDegradationCount`'s
3091
- * return value (`true` on the first occurrence for a kind/subject pair) is
3092
- * reused as that rising-edge gate instead of a second hand-rolled latch: it
3093
- * already re-arms at session_start via `resetDegradationLedger`, so this
3094
- * adds zero new module state. Never throws: telemetry must not perturb a
3095
- * path that has already decided to return `undefined` regardless.
3096
- */
3097
- function recordNavTimeoutTelemetry(args) {
3098
- // #1743: `risingEdgePer: "identity"` is the same gate this site hand-rolled
3099
- // off `incrementDegradationCount`'s return value, now expressed as an
3100
- // option. The ledger still counts every timeout exactly, per (method, file).
3101
- emitBounded("lsp_nav_request_timeout", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
3102
- type: "phase",
3103
- filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
3104
- durationMs: args.elapsedMs,
3105
- metadata: { method: args.method, effectiveBudgetMs: args.budgetMs },
3106
- }, {
3107
- ledgerKind: "lsp-nav-request-timeout",
3108
- risingEdgePer: "identity",
3109
- reason: `timeout budget=${args.budgetMs}ms elapsed=${args.elapsedMs}ms`,
3110
- });
3111
- }
3112
- /**
3113
- * #1716: nav-request sibling of #1713's `armLateAnswerTelemetry`. A
3114
- * telemetry-only continuation on the ALREADY-abandoned request promise —
3115
- * purely observational, never touches `state`, and attaching `.then` here
3116
- * creates no new timer, socket, or other handle, so it cannot keep the
3117
- * process alive a moment longer than the pending request already does.
3118
- *
3119
- * Bounded the same way as the timeout record above: every late answer is
3120
- * counted exactly via the ledger, but only the rising edge per (method,
3121
- * file) also writes an `lsp_nav_late_answer_discarded` record.
3122
- *
3123
- * Nav answers are read-once — unlike diagnostics' persistent pull cache,
3124
- * there is no stale value this could poison — so this exists purely for
3125
- * observability: telling a dogfood session "truly hung" apart from
3126
- * "answered late", the same distinction #1713 made possible for pulls.
3127
- */
3128
- function armNavLateAnswerTelemetry(args) {
3129
- args.requestPromise.then(() => {
3130
- try {
3131
- const elapsedMs = Date.now() - args.requestStartedAt;
3132
- // #1743: same rising-edge gate as the timeout record above, now
3133
- // carried by the helper instead of hand-rolled here.
3134
- emitBounded("lsp_nav_late_answer_discarded", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
3135
- type: "phase",
3136
- filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
3137
- durationMs: elapsedMs,
3138
- metadata: { method: args.method },
3139
- }, {
3140
- ledgerKind: "lsp-nav-late-answer",
3141
- risingEdgePer: "identity",
3142
- reason: `late nav answer discarded after ${elapsedMs}ms`,
3143
- });
3144
- }
3145
- catch {
3146
- // Telemetry must never break the observed path.
3147
- }
3148
- }, () => {
3149
- // The abandoned request eventually failed rather than answering —
3150
- // not an "answer discarded" event. Nothing more to record here; this
3151
- // handler exists so the request's eventual rejection never surfaces
3152
- // as an unhandled rejection.
3153
- });
3154
- }
3155
- /**
3156
- * #1969: pick the request the liveness probe may send to THIS server.
3157
- *
3158
- * The probe only needs a round-trip, so any request works — but it must be one
3159
- * the server advertised. #1277 originally hardcoded `workspace/symbol` because
3160
- * it needs no open document, and treated `MethodNotFound` as proof of life.
3161
- * That rationale is sound for the round-trip, and wrong as a default: a server
3162
- * that does not implement the method logs an error for every probe. ast-grep's
3163
- * tower_lsp backend wrote "got a 'workspace/symbol' request, but it is not
3164
- * implemented" on each one, and its `code=1` deaths clustered after them
3165
- * (#1969). pi-lens must not keep pushing an unimplemented method at a server
3166
- * just to learn it is awake.
3167
- *
3168
- * The ladder, best first:
3169
- * 1. `workspace/symbol` when `workspaceSymbolProvider` is advertised — the
3170
- * #1277 probe, unchanged, and still needs no open document.
3171
- * 2. `textDocument/documentSymbol` on an already-open document when
3172
- * `documentSymbolProvider` is advertised. Read-only, scoped to a document
3173
- * the server already has, and cheap.
3174
- * 3. `textDocument/hover` at the start of an already-open document when
3175
- * `hoverProvider` is advertised. This step is what keeps ast-grep covered:
3176
- * `docs/servercapabilities.md:53` records its advertised set as
3177
- * codeActionProvider, executeCommandProvider, hoverProvider and
3178
- * textDocumentSync — no symbol provider of either kind. #1714's own
3179
- * measurement (clients/lsp/index.ts, `paceAuxNotify`) timed hover against
3180
- * the real ast-grep binary at 1 ms idle and 2086 ms behind 30 `didOpen`s,
3181
- * the same ordering proof it recorded for `workspace/symbol`. So the
3182
- * notify throttle keeps a real barrier here, not a hollow one.
3183
- * 4. Nothing. The caller then reports liveness from process and connection
3184
- * state alone, and records that it did.
3185
- *
3186
- * `MethodNotFound`-counts-as-alive still holds for every probe: dynamic
3187
- * registration can retract a capability after `initialize`, and an error reply
3188
- * is still a reply. What changes is that pi-lens no longer PROVOKES that error
3189
- * on every probe.
3190
- */
3191
- function chooseLivenessProbe(state) {
3192
- if (state.operationSupport?.workspaceSymbol === true) {
3193
- return {
3194
- method: "workspace/symbol",
3195
- params: { query: LIVENESS_PING_QUERY },
3196
- };
3197
- }
3198
- const openUri = firstOpenDocumentUri(state);
3199
- if (openUri === undefined)
3200
- return undefined;
3201
- if (state.operationSupport?.documentSymbol === true) {
3202
- return {
3203
- method: "textDocument/documentSymbol",
3204
- params: { textDocument: { uri: openUri } },
3205
- };
3206
- }
3207
- if (state.operationSupport?.hover === true) {
3208
- return {
3209
- method: "textDocument/hover",
3210
- params: {
3211
- textDocument: { uri: openUri },
3212
- position: { line: 0, character: 0 },
3213
- },
3214
- };
3215
- }
3216
- return undefined;
3217
- }
3218
- /** URI of any document this client currently holds open, if it holds one. */
3219
- function firstOpenDocumentUri(state) {
3220
- for (const normalizedPath of state.openDocuments) {
3221
- const uri = state.openDocumentUris?.get(normalizedPath);
3222
- if (uri !== undefined)
3223
- return uri;
3224
- }
3225
- return undefined;
3226
- }
3227
- // #1277: cheap liveness round-trip used by the silent-clean gates in
3228
- // `index.ts`. `isAlive()`/`checkAlive()` only look at process/connection
3229
- // state — a server that accepted the notify write and then wedged (still
3230
- // running, connection still open, just never replying) reads as "alive" by
3231
- // those checks even though it will never answer anything again. This sends a
3232
- // real request (see `chooseLivenessProbe` for which, #1969) and reports
3233
- // whether the connection round-tripped it — success, a genuine
3234
- // protocol-level error (e.g. MethodNotFound), and a stream-destroyed/
3235
- // cancelled response (safeSendRequest swallows those to `undefined`, so the
3236
- // final `isClientAlive` re-check is what catches "died mid-flight") ALL count
3237
- // as "alive"; only a real timeout, or the connection having gone down by the
3238
- // time this resolves, reports dead. The response content itself is never
3239
- // inspected — only whether one arrived in time.
3240
- async function clientPingLiveness(state, timeoutMs = LIVENESS_PING_TIMEOUT_MS) {
3241
- if (!isClientAlive(state))
3242
- return false;
3243
- const probe = chooseLivenessProbe(state);
3244
- if (probe === undefined) {
3245
- // #1969: no advertised method to probe with, so this answer comes from
3246
- // process and connection state alone — exactly the checks the probe
3247
- // exists to strengthen. Say so in the ledger rather than let a weaker
3248
- // verdict pass as the strong one. Rising edge per server, via the
3249
- // ledger's own tally; the count carries the repeats.
3250
- try {
3251
- incrementDegradationCount({
3252
- kind: "lsp-liveness-probe-unsupported",
3253
- subject: state.serverId,
3254
- reason: "no advertised request method to probe with (no " +
3255
- "workspaceSymbolProvider, and no open document with " +
3256
- "documentSymbolProvider or hoverProvider); liveness from " +
3257
- "process and connection state only",
3258
- });
3259
- }
3260
- catch {
3261
- // Telemetry must never break the observed path.
3262
- }
3263
- return isClientAlive(state);
3264
- }
3265
- try {
3266
- await withTimeout(safeSendRequest(state.connection, probe.method, probe.params), timeoutMs);
3267
- }
3268
- catch (err) {
3269
- if (err instanceof Error && err.message.startsWith("Timeout after")) {
3270
- return false;
3271
- }
3272
- // A real protocol-level error reply still proves the server round-
3273
- // tripped the request — fall through to the alive re-check below
3274
- // rather than treating an error response as "dead".
3275
- }
3276
- return isClientAlive(state);
3277
- }
3278
- // #2479: `activeMutationContext` is DERIVED state, not a stack. The frame that
3279
- // takes `activeMutationDepth` 0 -> 1 owns the context for its whole lifetime
3280
- // (`mutationContextOwner`); the slot merely exposes that owner while it is the
3281
- // ONLY frame in flight. Deeper or overlapping frames therefore empty the slot
3282
- // for as long as they are open — concurrent and nested commands deliberately
3283
- // do not cross-correlate — and it refills by itself when they unwind, with no
3284
- // frame having to remember anything.
3285
- //
3286
- // Recomputing beats saving and restoring because these frames do not nest:
3287
- // `LSPClient.executeCommand` and `LSPService.executeCommand` fan out with no
3288
- // mutex, parallel `lsp_navigation` calls with `apply: true` overlap, and #449
3289
- // light mode shares one client across agents. With A (the owner), B and C
3290
- // overlapping and the MIDDLE one settling first, a per-frame restore hands A's
3291
- // context back while C's window is the open one — and `LspMutationContext`
3292
- // carries a cwd plus a directly-threaded runtime/cacheManager, with
3293
- // `readTurnState` / `appendProjectChange` cwd-scoped, so that routes a live
3294
- // edit's bookkeeping into another project's change log (strictly worse than
3295
- // the honest fallback the pre-#2479 code gave). An owner-liveness flag does
3296
- // not fix it either: it answers "is an owner live", not "does the slot belong
3297
- // to the window this edit is in" — and C's own unwind then puts back the
3298
- // `undefined` it saved while the owner is still running, resurrecting #2479
3299
- // (review round 3). Deriving answers the second question by construction, and
3300
- // "no command in flight == no context" — the invariant the
3301
- // `serverEditsAllowed` gate on `workspace/applyEdit` is paired with — falls
3302
- // out of it with no depth-0 special case.
3303
- function syncMutationContextSlot(state) {
3304
- state.activeMutationContext =
3305
- state.activeMutationDepth === 1 ? state.mutationContextOwner : undefined;
3306
- }
3307
- // Run an advertised server command via workspace/executeCommand, with the
3308
- // generous EXECUTE_COMMAND_TIMEOUT_MS anti-deadlock backstop. Preserves the
3309
- // hardening invariants: allowlist-by-advertisement (only commands the server
3310
- // declared) and the serverEditsAllowed window that gates server-driven
3311
- // applyEdit to the duration of an explicit call. Exported with an overridable
3312
- // `timeoutMs` for the #365 regression tests.
3313
- export async function runServerCommand(state, command, args, timeoutMs = EXECUTE_COMMAND_TIMEOUT_MS, mutationContext) {
3314
- if (!isClientAlive(state)) {
3315
- return { executed: false, reason: "lsp client not alive" };
3316
- }
3317
- if (!state.advertisedCommands.has(command)) {
3318
- return {
3319
- executed: false,
3320
- reason: `command "${command}" is not advertised by the ${state.serverId} server`,
3321
- };
3322
- }
3323
- state.serverEditsAllowed += 1;
3324
- state.activeMutationDepth = (state.activeMutationDepth ?? 0) + 1;
3325
- // #2479: the frame that takes the depth to 1 owns the context; the slot is
3326
- // derived from that owner (see `syncMutationContextSlot` above). The
3327
- // pre-#2479 shape only ever CLEARED the slot, and only once the depth
3328
- // returned to 0, so a nested `executeCommand` unwinding from depth 2 back
3329
- // to 1 left the OUTER call without its own context for the rest of its
3330
- // window: every server-initiated `applyEdit` it still solicited read
3331
- // `undefined`, fell to the mutation bridge, and carried the generic
3332
- // `agent-tool:lsp-workspace-applyEdit` receipt instead of the outer
3333
- // operation's own (`lsp-rename` / `lsp-execute-command`).
3334
- const isMutationContextOwner = state.activeMutationDepth === 1;
3335
- if (isMutationContextOwner) {
3336
- state.mutationContextOwner = mutationContext;
3337
- }
3338
- syncMutationContextSlot(state);
3339
- try {
3340
- let result;
3341
- try {
3342
- result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
3343
- command,
3344
- arguments: args ?? [],
3345
- }), timeoutMs);
3346
- }
3347
- catch (err) {
3348
- // Generous backstop only: a timeout means the server is hung (or the
3349
- // command is running longer than the ceiling). Surface it honestly — the
3350
- // command may still be applying — instead of hanging the caller. Real
3351
- // (non-timeout) errors still propagate.
3352
- if (err instanceof Error && err.message.startsWith("Timeout after")) {
3353
- return {
3354
- executed: false,
3355
- reason: `workspace/executeCommand timed out after ${timeoutMs}ms — the command may still be applying server-side`,
3356
- };
3357
- }
3358
- throw err;
3359
- }
3360
- return { executed: true, result };
3361
- }
3362
- finally {
3363
- state.serverEditsAllowed -= 1;
3364
- state.activeMutationDepth = Math.max(0, (state.activeMutationDepth ?? 0) - 1);
3365
- // The owner drops its context; every frame then resyncs. Both the
3366
- // hand-back to a still-live owner once the deeper frames unwind and
3367
- // the empty slot while more than one frame is open are consequences of
3368
- // the derivation, not separate rules each frame has to get right
3369
- // (#2479 review rounds 2/3).
3370
- if (isMutationContextOwner) {
3371
- state.mutationContextOwner = undefined;
3372
- }
3373
- syncMutationContextSlot(state);
3374
- }
3375
- }
3376
- // #1412 H1/H2: read-only sibling of runServerCommand for telemetry/identity
3377
- // probes that must NOT participate in the mutation-acceptance window. Unlike
3378
- // runServerCommand this never touches serverEditsAllowed, activeMutationDepth,
3379
- // or activeMutationContext — a probe firing mid-flight must leave a concurrent
3380
- // real executeCommand's mutation context untouched, and must not itself open
3381
- // the workspace/applyEdit acceptance window (client.ts's applyEdit handler
3382
- // gates on serverEditsAllowed > 0). Preserves the allowlist-by-advertisement
3383
- // invariant. Short PROBE_COMMAND_TIMEOUT_MS backstop — this is a diagnostic
3384
- // sample, not a mutation, and must never hold anything up for anywhere near
3385
- // EXECUTE_COMMAND_TIMEOUT_MS.
3386
- export async function runReadOnlyServerCommand(state, command, args, timeoutMs = PROBE_COMMAND_TIMEOUT_MS) {
3387
- if (!isClientAlive(state)) {
3388
- return { executed: false, reason: "lsp client not alive" };
3389
- }
3390
- if (!state.advertisedCommands.has(command)) {
3391
- return {
3392
- executed: false,
3393
- reason: `command "${command}" is not advertised by the ${state.serverId} server`,
3394
- };
3395
- }
3396
- try {
3397
- const result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
3398
- command,
3399
- arguments: args ?? [],
3400
- }), timeoutMs);
3401
- return { executed: true, result };
3402
- }
3403
- catch (err) {
3404
- if (err instanceof Error && err.message.startsWith("Timeout after")) {
3405
- return {
3406
- executed: false,
3407
- reason: `workspace/executeCommand timed out after ${timeoutMs}ms`,
3408
- };
3409
- }
3410
- throw err;
3411
- }
3412
- }
3413
- function validateWorkspaceEditVersions(state, edit) {
3414
- for (const change of edit.documentChanges ?? []) {
3415
- if (typeof change !== "object" ||
3416
- change === null ||
3417
- !("textDocument" in change))
3418
- continue;
3419
- const textDocument = change.textDocument;
3420
- if (!textDocument ||
3421
- typeof textDocument.uri !== "string" ||
3422
- textDocument.version == null)
3423
- continue;
3424
- const current = state.documentVersions.get(normalizeMapKey(uriToPath(textDocument.uri)));
3425
- if (current === undefined || current !== textDocument.version) {
3426
- throw new Error(`stale workspace edit document version for ${textDocument.uri}`);
3427
- }
3428
- }
3429
- }
3430
- // Neutralize numeric `textDocument.version` stamps AFTER they have been
3431
- // validated against the live document map. The tool apply paths (rename
3432
- // apply:true in tools/lsp-navigation.ts, code-action autofix in
3433
- // actionable-warnings.ts) call applyWorkspaceEdit without a documentVersions
3434
- // map, so a preserved numeric version would fail preflight 100% of the time for
3435
- // servers that stamp real versions (gopls stamps open documents). Setting the
3436
- // version to null is the spec's "do not check" — the freshness guarantee has
3437
- // already been provided here by validateWorkspaceEditVersions at the correct
3438
- // moment. The server-initiated workspace/applyEdit handler does NOT route
3439
- // through here (it applies params.edit directly with state.documentVersions),
3440
- // so its real preflight version check is left fully intact.
3441
- function stripDocumentVersions(edit) {
3442
- if (!Array.isArray(edit.documentChanges))
3443
- return edit;
3444
- const documentChanges = edit.documentChanges.map((change) => {
3445
- if (typeof change === "object" &&
3446
- change !== null &&
3447
- "textDocument" in change &&
3448
- "edits" in change) {
3449
- const textDocument = change
3450
- .textDocument;
3451
- if (textDocument && typeof textDocument.version === "number") {
3452
- return {
3453
- ...change,
3454
- textDocument: { ...textDocument, version: null },
3455
- };
3456
- }
3457
- }
3458
- return change;
3459
- });
3460
- return { ...edit, documentChanges };
3461
- }
3462
- export async function normalizeClientWorkspaceEdit(state, edit) {
3463
- validateWorkspaceEditVersions(state, edit);
3464
- const normalized = (await normalizeWorkspaceEditToUtf16(edit, state.positionEncoding, state.root));
3465
- return stripDocumentVersions(normalized);
3466
- }
3467
- async function resolveCodeActionBestEffort(state, action) {
3468
- if (!isClientAlive(state))
3469
- return action;
3470
- if (action.edit) {
3471
- return {
3472
- ...action,
3473
- edit: await normalizeClientWorkspaceEdit(state, action.edit),
3474
- };
3475
- }
3476
- if (!state.operationSupport.codeActionResolve) {
3477
- recordDegradationOnce({
3478
- kind: "lsp-capability-skip",
3479
- subject: `${state.serverId}:codeAction/resolve`,
3480
- reason: "server did not advertise codeActionProvider.resolveProvider",
3481
- });
3482
- return action;
3483
- }
3484
- let resolved;
3485
- try {
3486
- resolved = await withTimeout(safeSendRequest(state.connection, "codeAction/resolve", action), NAV_REQUEST_TIMEOUT_MS);
3487
- }
3488
- catch {
3489
- // codeAction/resolve is optional. Keep the original lightweight action when
3490
- // the server does not support resolve or fails to populate an edit.
3491
- return action;
3492
- }
3493
- if (!resolved || typeof resolved !== "object")
3494
- return action;
3495
- const merged = { ...action, ...resolved };
3496
- return merged.edit
3497
- ? {
3498
- ...merged,
3499
- edit: await normalizeClientWorkspaceEdit(state, merged.edit),
3500
- }
3501
- : merged;
3502
- }
3503
- // --- Client Factory ---
3504
- export async function createLSPClient(options) {
3505
- installCrashGuard();
3506
- const { serverId, process: lspProcess, root, sessionCwd, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, onDiagnosticsPublished, } = options;
3507
- // #449/#472: register this LSP child in the cross-process instance registry
3508
- // as soon as we have a live pid — BEFORE `initialize` completes, not after.
3509
- // Registering early means a child that dies/hangs during initialize (the
3510
- // catch block below kills it) is still deregistered by that same path via
3511
- // removeLspChild, and a process that crashes mid-initialize is still
3512
- // visible to the orphan reaper rather than silently untracked. Fire-and-
3513
- // forget: registry I/O must never block or fail LSP startup.
3514
- void recordLspChild({
3515
- pid: lspProcess.pid,
3516
- serverId,
3517
- command: lspProcess.command,
3518
- marker: extractSpawnMarker(lspProcess.args),
3519
- sessionIdentity: {
3520
- projectRoot: sessionCwd ?? process.cwd(),
3521
- rootSource: sessionCwd ? "service-cwd" : "lsp-fallback",
3522
- startedAt: new Date(workspaceDiagnosticsCacheSessionStart()).toISOString(),
3523
- },
3524
- }).catch((err) => {
3525
- // best-effort observability — never fail LSP startup over this
3526
- logLatency({
3527
- type: "phase",
3528
- phase: "lsp_registry_write_failed",
3529
- filePath: "",
3530
- durationMs: 0,
3531
- metadata: { op: "record", pid: lspProcess.pid, error: String(err) },
3532
- });
3533
- });
3534
- const startupState = {
3535
- exitCode: null,
3536
- exitSignal: null,
3537
- closeCode: null,
3538
- closeSignal: null,
3539
- stderr: "",
3540
- };
3541
- // Persistent stderr ring buffer — captures last ~100 lines for diagnostics.
3542
- // Used in error messages to show what the server said before dying.
3543
- const stderrRing = [];
3544
- const MAX_STDERR_LINES = 100;
3545
- const onStderr = (chunk) => {
3546
- stderrRing.push(chunk.toString());
3547
- if (stderrRing.length > MAX_STDERR_LINES)
3548
- stderrRing.shift();
3549
- // Also capture startup stderr for the initialized-failed error path
3550
- if (startupState.stderr.length < 4096) {
3551
- startupState.stderr += chunk.toString();
3552
- }
3553
- };
3554
- const recentStderr = (lines = 10) => stderrRing.slice(-lines).join("").trim();
3555
- // Pre-request health check — returns error string if process is dead.
3556
- const checkProcessAlive = () => {
3557
- const exited = lspProcess.process.exitCode;
3558
- if (exited !== null) {
3559
- const tail = recentStderr(20);
3560
- return `LSP server ${serverId} exited with code ${exited}${tail ? `. stderr: ${tail}` : ""}`;
3561
- }
3562
- if (lspProcess.process.killed) {
3563
- return `LSP server ${serverId} was killed`;
3564
- }
3565
- return undefined;
3566
- };
3567
- const onProcessExit = (code, signal) => {
3568
- startupState.exitCode = code;
3569
- startupState.exitSignal = signal;
3570
- };
3571
- const onProcessClose = (code, signal) => {
3572
- startupState.closeCode = code;
3573
- startupState.closeSignal = signal;
3574
- };
3575
- lspProcess.stderr.on("data", onStderr);
3576
- lspProcess.process.on("exit", onProcessExit);
3577
- lspProcess.process.on("close", onProcessClose);
3578
- // Attach persistent 'error' listeners to all three stdio streams.
3579
- //
3580
- // Why: when the LSP process exits, Node.js destroys its stdio streams and
3581
- // may emit 'error' (ERR_STREAM_DESTROYED / EPIPE / ECONNRESET) on them.
3582
- // Without a listener that becomes an uncaught exception.
3583
- //
3584
- // vscode-jsonrpc covers stdin/stdout during the connection lifetime but
3585
- // removes its listeners on dispose(). Our permanent listeners cover the gap.
3586
- const streamErrorHandler = (_label) => (err) => {
3587
- if (err.code === "ERR_STREAM_DESTROYED" ||
3588
- err.code === "ERR_STREAM_WRITE_AFTER_END" ||
3589
- err.code === "EPIPE" ||
3590
- err.code === "ECONNRESET")
3591
- return;
3592
- };
3593
- lspProcess.stdin.on("error", streamErrorHandler("stdin"));
3594
- lspProcess.stdout.on("error", streamErrorHandler("stdout"));
3595
- lspProcess.stderr.on("error", streamErrorHandler("stderr"));
3596
- const connection = createMessageConnection(new StreamMessageReader(lspProcess.stdout), new StreamMessageWriter(lspProcess.stdin));
3597
- // Local event emitter — signals waitForDiagnostics when new diagnostics arrive.
3598
- // Scoped to this client instance. setMaxListeners guards against Node.js warning
3599
- // for concurrent waitForDiagnostics calls.
3600
- const diagnosticEmitter = new EventEmitter();
3601
- diagnosticEmitter.setMaxListeners(50);
3602
- // SAFETY: three fields below are seeded `undefined as unknown as T` because
3603
- // they cannot be built here. `workspaceDiagnosticsSupport` and
3604
- // `operationSupport` are derived from the server's `initialize` result, and
3605
- // `watchQueue`'s flush closure needs `state` itself. Each is assigned
3606
- // in this same function before `state` reaches any caller:
3607
- // `state.watchQueue` on the statement immediately after this literal, and
3608
- // the two capability fields right after the `initialize` handshake
3609
- // returns. Reorder that and the fields are genuinely `undefined` at their
3610
- // declared non-optional types.
3611
- const state = {
3612
- isConnected: true,
3613
- isDestroyed: false,
3614
- shutdownRequested: false,
3615
- shutdownPromise: undefined,
3616
- shutdownOptions: undefined,
3617
- exitedAt: undefined,
3618
- connectionDisposed: false,
3619
- lastError: undefined,
3620
- connection,
3621
- pushDiagnostics: new Map(),
3622
- pushDiagnosticTimestamps: new Map(),
3623
- documentPullDiagnostics: new Map(),
3624
- documentPullDiagnosticTimestamps: new Map(),
3625
- pullFailureHistory: [],
3626
- pendingDiagnostics: new Map(),
3627
- diagnosticPublicationCounts: new Map(),
3628
- documentOpenedAt: new Map(),
3629
- diagnosticEmitter,
3630
- diagnosticsVersion: 0,
3631
- diagnosticsVersionsByPath: new Map(),
3632
- documentVersions: new Map(),
3633
- notifyChangeQueues: new Map(),
3634
- diagnosticDocVersions: new Map(),
3635
- documentContentHashes: new Map(),
3636
- incrementalTextRetainedEntries: 0,
3637
- incrementalTextBearingPaths: new Set(),
3638
- incrementalTextRetainedBytes: 0,
3639
- diagnosticBindings: new Map(),
3640
- pullResultIds: new Map(),
3641
- documentPullDiagnosticsBySource: new Map(),
3642
- pullGenerations: new Map(),
3643
- pullRequestSequences: new Map(),
3644
- workspacePullResultCache: new Map(),
3645
- openDocuments: new Set(),
3646
- closedDocuments: new Set(),
3647
- openDocumentUris: new Map(),
3648
- pendingOpens: new Set(),
3649
- projectIdentityProbedFiles: new Set(),
3650
- // these are filled in after initialize — cast to avoid two-phase init
3651
- workspaceDiagnosticsSupport:
3652
- // SAFETY: initialize fills this capability before any workspace diagnostic request.
3653
- undefined,
3654
- // SAFETY: initialize fills this capability before operation dispatch.
3655
- operationSupport: undefined,
3656
- staticDiagnosticsMode: "push-only",
3657
- positionEncoding: "utf-16",
3658
- syncKind: TEXT_DOCUMENT_SYNC_KIND_FULL,
3659
- dynamicRegistrations: new Map(),
3660
- advertisedCommands: new Set(),
3661
- serverEditsAllowed: 0,
3662
- activeMutationDepth: 0,
3663
- serverId,
3664
- launchVariant,
3665
- root,
3666
- lspProcess,
3667
- // two-phase: the flush closure needs `state` (below)
3668
- // SAFETY: state construction completes before the flush closure can run.
3669
- watchQueue: undefined,
3670
- };
3671
- activeLspClientSet().add(state);
3672
- // #271: batch per-file workspace/didChangeWatchedFiles into one notification
3673
- // per debounce window, so an N-file turn re-indexes the server once, not N×.
3674
- state.watchQueue = new WatchedFilesQueue((changes) => {
3675
- if (!isClientAlive(state))
3676
- return;
3677
- void safeSendNotification(state.connection, "workspace/didChangeWatchedFiles", { changes });
3678
- });
3679
- setupIncomingHandlers(state, initialization, onDiagnosticsPublished);
3680
- connection.listen();
3681
- setupConnectionLifecycle(state, recentStderr);
3682
- let initResult;
3683
- try {
3684
- initResult = await withTimeout(safeSendRequest(connection, "initialize", {
3685
- processId: process.pid,
3686
- rootUri: pathToFileURL(root).href,
3687
- workspaceFolders: [
3688
- { name: "workspace", uri: pathToFileURL(root).href },
3689
- ],
3690
- capabilities: CLIENT_CAPABILITIES,
3691
- initializationOptions: initialization,
3692
- }), initializeTimeoutMs);
3693
- }
3694
- catch (err) {
3695
- // #1969: claim this kill BEFORE issuing it. `setupConnectionLifecycle`
3696
- // ran above, so its `exit` and `close` handlers are already armed on
3697
- // this child, and both read `state.shutdownRequested` to tell a crash
3698
- // from a teardown we asked for. The kill below is one we asked for.
3699
- // Without this line it reported as an unprompted death, fabricating a
3700
- // `lsp-server-unexpected-close` entry reading
3701
- // "code=1 signal=none stderr=empty" — character for character the
3702
- // ast-grep signature this issue exists to make trustworthy — every time
3703
- // a server merely failed to complete its handshake. It also silenced a
3704
- // false `lsp_server_unexpected_exit` latency line that this path has
3705
- // been writing since the #615 follow-up.
3706
- state.shutdownRequested = true;
3707
- // Hard-kill the hung process so it doesn't become a zombie.
3708
- // SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
3709
- const pid = lspProcess.pid;
3710
- void killProcessTree(lspProcess.process, pid);
3711
- // A child registered above (recordLspChild) but never reaching a healthy
3712
- // createLSPClient return must still be deregistered here — otherwise the
3713
- // registry keeps a stale entry for a process we just killed.
3714
- void removeLspChild(pid, extractSpawnMarker(lspProcess.args)).catch((err) => {
3715
- // best-effort — a stale registry entry is harmless (the reaper's
3716
- // liveness check will find it dead on the next sweep regardless)
3717
- logLatency({
3718
- type: "phase",
3719
- phase: "lsp_registry_write_failed",
3720
- filePath: "",
3721
- durationMs: 0,
3722
- metadata: { op: "remove", pid, error: String(err) },
3723
- });
3724
- });
3725
- setTimeout(() => {
3726
- // #1114: gate on the process's own observed `exitCode`/`signalCode`,
3727
- // not `.killed` — `killProcessTree` above signals the POSIX process
3728
- // GROUP via the raw `process.kill(-pid, …)`, which never touches
3729
- // this `ChildProcess` instance's `.killed` flag, so `!…killed` here
3730
- // was always true and this 2s backstop unconditionally re-sent
3731
- // SIGKILL even when the group had already exited. `exitCode` alone
3732
- // is insufficient too: a process that died FROM a signal (the
3733
- // common case here — killProcessTree's own SIGTERM/SIGKILL) has
3734
- // `exitCode === null` forever and only `signalCode` set, so
3735
- // checking `exitCode === null` alone still re-SIGKILLs that corpse
3736
- // on the common path (harmless — `kill()` on an already-exited pid
3737
- // is a swallowed no-op — but not actually "observed still alive").
3738
- // Require both null to mean "no exit observed by either signal".
3739
- if (lspProcess.process.exitCode === null &&
3740
- lspProcess.process.signalCode === null &&
3741
- process.platform !== "win32") {
3742
- lspProcess.process.kill("SIGKILL");
3743
- }
3744
- }, 2000);
3745
- throw err;
3746
- }
3747
- finally {
3748
- lspProcess.stderr.off("data", onStderr);
3749
- }
3750
- if (initResult === undefined) {
3751
- const compactStderr = startupState.stderr
3752
- .replace(/\s+/g, " ")
3753
- .trim()
3754
- .slice(0, 320);
3755
- const reinstallHint = serverId === "cpp"
3756
- ? "Install clangd (LLVM/clang-tools) and ensure clangd.exe is on PATH."
3757
- : `Try reinstalling: npm install -g ${serverId}-language-server.`;
3758
- const telemetry = [
3759
- `pid=${lspProcess.pid}`,
3760
- `exitCode=${startupState.exitCode ?? "none"}`,
3761
- `exitSignal=${startupState.exitSignal ?? "none"}`,
3762
- `closeCode=${startupState.closeCode ?? "none"}`,
3763
- `closeSignal=${startupState.closeSignal ?? "none"}`,
3764
- `root=${root}`,
3765
- compactStderr ? `stderr=${compactStderr}` : "stderr=<empty>",
3766
- ].join(" ");
3767
- throw new Error(`[lsp] ${serverId} failed to initialize - stream may have been destroyed. ` +
3768
- `The server binary may be missing or crashed immediately. ${reinstallHint} ` +
3769
- `telemetry: ${telemetry}`);
3770
- }
3771
- state.workspaceDiagnosticsSupport =
3772
- detectWorkspaceDiagnosticsSupport(initResult);
3773
- state.operationSupport = detectOperationSupport(initResult);
3774
- const capabilities = (initResult
3775
- ?.capabilities ?? {});
3776
- const workspace = isCapabilityRecord(capabilities.workspace)
3777
- ? capabilities.workspace
3778
- : undefined;
3779
- const fileOperations = isCapabilityRecord(workspace)
3780
- ? workspace.fileOperations
3781
- : undefined;
3782
- const malformedFileOperationRegistrations = new Set();
3783
- if (isCapabilityRecord(fileOperations)) {
3784
- for (const operation of ["willRename", "didRename"]) {
3785
- if (fileOperations[operation] !== undefined &&
3786
- parseFileOperationFilters(fileOperations[operation]) === undefined) {
3787
- malformedFileOperationRegistrations.add(operation);
3788
- }
3789
- }
3790
- }
3791
- state.fileOperationMalformedRegistrations =
3792
- malformedFileOperationRegistrations;
3793
- if (isCapabilityRecord(fileOperations)) {
3794
- state.fileOperationFilters = {
3795
- willRename: parseFileOperationFilters(fileOperations.willRename),
3796
- didRename: parseFileOperationFilters(fileOperations.didRename),
3797
- };
3798
- }
3799
- state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
3800
- state.syncKind = negotiateSyncKind(initResult?.capabilities);
3801
- state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
3802
- {}).sort((a, b) => a.localeCompare(b));
3803
- for (const cmd of detectExecuteCommands(initResult)) {
3804
- state.advertisedCommands.add(cmd);
3805
- }
3806
- state.staticDiagnosticsMode = state.workspaceDiagnosticsSupport.mode;
3807
- await safeSendNotification(connection, "initialized", {});
3808
- if (initialization) {
3809
- await safeSendNotification(connection, "workspace/didChangeConfiguration", {
3810
- settings: initialization,
3811
- });
3812
- }
3813
- return {
3814
- serverId,
3815
- root,
3816
- connection,
3817
- isAlive: () => isClientAlive(state),
3818
- /** True if the server process has exited or been killed. */
3819
- processExited: () => lspProcess.process.exitCode !== null ||
3820
- lspProcess.process.killed === true,
3821
- /** #1127: mirrors `state.shutdownRequested` — see interface doc. */
3822
- wasShutdownIntentional: () => state.shutdownRequested,
3823
- /** #1127: mirrors `state.exitedAt` — see interface doc. */
3824
- getExitedAt: () => state.exitedAt,
3825
- /** Last N lines of server stderr for diagnostics. */
3826
- recentStderr: (lines) => recentStderr(lines),
3827
- getPullFailureHistory: () => state.pullFailureHistory.map((entry) => ({
3828
- ...entry,
3829
- message: entry.message.slice(0, 200),
3830
- })),
3831
- /** Pre-request health check — returns error string if dead. */
3832
- checkAlive: () => checkProcessAlive(),
3833
- /** #1277: cheap request round-trip proving the server still responds. */
3834
- pingLiveness: (timeoutMs) => clientPingLiveness(state, timeoutMs),
3835
- /** #2358: the OS pid of the live server process (see interface doc). */
3836
- getProcessPid: () => lspProcess.pid,
3837
- notify: {
3838
- async open(filePath, content, languageId, preserveDiagnostics, silent) {
3839
- return handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics, silent);
3840
- },
3841
- async change(filePath, content) {
3842
- return handleNotifyChange(state, filePath, content);
3843
- },
3844
- watchedFileChange(filePath, type) {
3845
- handleNotifyExternalChange(state, filePath, type);
3846
- },
3847
- },
3848
- getDiagnostics(filePath) {
3849
- return getMergedDiagnosticsForPath(state, normalizeMapKey(filePath));
3850
- },
3851
- getDiagnosticBinding(filePath) {
3852
- return state.diagnosticBindings.get(normalizeMapKey(filePath));
3853
- },
3854
- getDiagnosticsVersionForPath(filePath) {
3855
- return diagnosticsVersionForPath(state, normalizeMapKey(filePath));
3856
- },
3857
- getAllDiagnostics() {
3858
- const result = new Map();
3859
- const keys = new Set([
3860
- ...state.pushDiagnostics.keys(),
3861
- ...state.documentPullDiagnostics.keys(),
3862
- ]);
3863
- for (const key of keys) {
3864
- result.set(key, {
3865
- diags: getMergedDiagnosticsForPath(state, key),
3866
- ts: Math.max(state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0),
3867
- binding: state.diagnosticBindings.get(key),
3868
- });
3869
- }
3870
- return result;
3871
- },
3872
- getTrackedDiagnosticPaths() {
3873
- return [
3874
- ...new Set([
3875
- ...state.pushDiagnostics.keys(),
3876
- ...state.documentPullDiagnostics.keys(),
3877
- ]),
3878
- ].map((filePath) => process.platform === "win32" ? filePath.replace(/\//g, "\\") : filePath);
3879
- },
3880
- pruneDiagnostics(predicate) {
3881
- let removed = 0;
3882
- const keys = new Set([
3883
- ...state.pushDiagnostics.keys(),
3884
- ...state.documentPullDiagnostics.keys(),
3885
- ]);
3886
- for (const key of keys) {
3887
- const diags = getMergedDiagnosticsForPath(state, key);
3888
- const ts = Math.max(state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0);
3889
- if (!predicate(key, ts, diags))
3890
- continue;
3891
- clearDiagnosticsForPath(state, key);
3892
- removed++;
3893
- }
3894
- return removed;
3895
- },
3896
- getWorkspaceDiagnosticsSupport() {
3897
- return state.workspaceDiagnosticsSupport;
3898
- },
3899
- requestWorkspaceDiagnostics(budgetMs) {
3900
- return clientRequestWorkspaceDiagnostics(state, budgetMs);
3901
- },
3902
- getOperationSupport() {
3903
- return state.operationSupport;
3904
- },
3905
- getMalformedFileOperationRegistrations() {
3906
- return state.fileOperationMalformedRegistrations ?? new Set();
3907
- },
3908
- getAdvertisedCommands() {
3909
- return [...state.advertisedCommands];
3910
- },
3911
- getRawCapabilityKeys() {
3912
- return state.rawCapabilityKeys ?? [];
3913
- },
3914
- getLaunchVariant() {
3915
- return state.launchVariant;
3916
- },
3917
- async executeCommand(command, args, mutationContext) {
3918
- return runServerCommand(state, command, args, EXECUTE_COMMAND_TIMEOUT_MS, mutationContext);
3919
- },
3920
- async executeReadOnlyCommand(command, args) {
3921
- return runReadOnlyServerCommand(state, command, args);
3922
- },
3923
- get diagnosticsVersion() {
3924
- return state.diagnosticsVersion;
3925
- },
3926
- async waitForDiagnostics(filePath, timeoutMs = DIAGNOSTICS_WAIT_TIMEOUT_MS, options) {
3927
- return clientWaitForDiagnostics(state, filePath, timeoutMs, options);
3928
- },
3929
- async definition(filePath, line, character) {
3930
- const result = await navRequest(state, "textDocument/definition", {
3931
- textDocument: { uri: pathToFileURL(filePath).href },
3932
- position: await toWirePosition(state, filePath, line, character),
3933
- }, filePath);
3934
- if (!result)
3935
- return [];
3936
- return Array.isArray(result) ? result : [result];
3937
- },
3938
- async typeDefinition(filePath, line, character) {
3939
- const result = await navRequest(state, "textDocument/typeDefinition", {
3940
- textDocument: { uri: pathToFileURL(filePath).href },
3941
- position: await toWirePosition(state, filePath, line, character),
3942
- }, filePath);
3943
- if (!result)
3944
- return [];
3945
- return Array.isArray(result) ? result : [result];
3946
- },
3947
- async declaration(filePath, line, character) {
3948
- const result = await navRequest(state, "textDocument/declaration", {
3949
- textDocument: { uri: pathToFileURL(filePath).href },
3950
- position: await toWirePosition(state, filePath, line, character),
3951
- }, filePath);
3952
- if (!result)
3953
- return [];
3954
- return Array.isArray(result) ? result : [result];
3955
- },
3956
- async references(filePath, line, character, includeDeclaration = true) {
3957
- const result = await navRequest(state, "textDocument/references", {
3958
- textDocument: { uri: pathToFileURL(filePath).href },
3959
- position: await toWirePosition(state, filePath, line, character),
3960
- context: { includeDeclaration },
3961
- }, filePath);
3962
- return result ?? [];
3963
- },
3964
- async hover(filePath, line, character) {
3965
- const result = await navRequest(state, "textDocument/hover", {
3966
- textDocument: { uri: pathToFileURL(filePath).href },
3967
- position: await toWirePosition(state, filePath, line, character),
3968
- }, filePath);
3969
- return result ?? null;
3970
- },
3971
- async signatureHelp(filePath, line, character) {
3972
- const result = await navRequest(state, "textDocument/signatureHelp", {
3973
- textDocument: { uri: pathToFileURL(filePath).href },
3974
- position: await toWirePosition(state, filePath, line, character),
3975
- }, filePath);
3976
- return result ?? null;
3977
- },
3978
- async documentSymbol(filePath) {
3979
- const result = await navRequest(state, "textDocument/documentSymbol", { textDocument: { uri: pathToFileURL(filePath).href } }, filePath);
3980
- return result ?? [];
3981
- },
3982
- isDocumentOpen(filePath) {
3983
- return state.openDocuments.has(normalizeMapKey(filePath));
3984
- },
3985
- openDocumentPaths() {
3986
- return [...state.openDocuments];
3987
- },
3988
- isBusy() {
3989
- return (activeRequestsByConnection.get(connection) ?? 0) > 0;
3990
- },
3991
- getDocumentUri(filePath) {
3992
- return state.openDocumentUris?.get(normalizeMapKey(filePath));
3993
- },
3994
- async workspaceSymbol(query) {
3995
- if (!isClientAlive(state))
3996
- return [];
3997
- // Route through navRequest for the shared withTimeout ceiling — a hung
3998
- // server would otherwise await forever (safeSendRequest only settles on
3999
- // a reply or a destroyed stream). No staleCheckPath: not single-file.
4000
- const result = await navRequest(state, "workspace/symbol", {
4001
- query,
4002
- });
4003
- return result ?? [];
4004
- },
4005
- async codeAction(filePath, line, character, endLine, endCharacter) {
4006
- if (!isClientAlive(state))
4007
- return [];
4008
- const uri = pathToFileURL(filePath).href;
4009
- // navRequest adds the shared withTimeout ceiling + single-file
4010
- // stale-drop (matches documentSymbol); a hung server no longer awaits
4011
- // forever, and code actions computed against superseded content drop.
4012
- const result = await navRequest(state, "textDocument/codeAction", {
4013
- textDocument: { uri },
4014
- range: {
4015
- start: await toWirePosition(state, filePath, line, character),
4016
- end: await toWirePosition(state, filePath, endLine, endCharacter),
4017
- },
4018
- context: {
4019
- diagnostics: getMergedDiagnosticsForPath(state, normalizeMapKey(filePath)),
4020
- },
4021
- }, filePath);
4022
- if (!result || !Array.isArray(result))
4023
- return [];
4024
- const actions = result.filter((item) => typeof item === "object" && item !== null && "title" in item);
4025
- return Promise.all(actions.map((action) => resolveCodeActionBestEffort(state, action)));
4026
- },
4027
- async rename(filePath, line, character, newName) {
4028
- const result = await navRequest(state, "textDocument/rename", {
4029
- textDocument: { uri: pathToFileURL(filePath).href },
4030
- position: await toWirePosition(state, filePath, line, character),
4031
- newName,
4032
- }, filePath);
4033
- return result ? await normalizeClientWorkspaceEdit(state, result) : null;
4034
- },
4035
- closeDocument: (filePath) => closeDocument(state, filePath),
4036
- async willRenameFiles(oldFilePath, newFilePath) {
4037
- if (!isClientAlive(state))
4038
- return null;
4039
- // #1971 review: the server registered WHICH paths it cares about.
4040
- // Sending outside those filters asks a question the server never
4041
- // signed up to answer.
4042
- const oldUri = pathToFileURL(oldFilePath).href;
4043
- const newUri = pathToFileURL(newFilePath).href;
4044
- if (!(await fileOperationFiltersMatch(state.fileOperationFilters?.willRename, oldUri, newUri, oldFilePath, newFilePath))) {
4045
- recordDegradationOnce({
4046
- kind: "lsp-capability-skip",
4047
- subject: `${state.serverId}:workspace/willRenameFiles:filter`,
4048
- reason: "filter-mismatch",
4049
- });
4050
- return null;
4051
- }
4052
- const result = await navRequest(state, "workspace/willRenameFiles", {
4053
- files: [
4054
- {
4055
- oldUri,
4056
- newUri,
4057
- },
4058
- ],
4059
- });
4060
- return result ? await normalizeClientWorkspaceEdit(state, result) : null;
4061
- },
4062
- async didRenameFiles(oldFilePath, newFilePath, oldUri, newUri) {
4063
- if (!isClientAlive(state))
4064
- return;
4065
- // `fileOperationFilters.didRename` is parsed by the same predicate that
4066
- // derives operationSupport.didRenameFiles (see
4067
- // isFileOperationRegistrationOptions). Thus filters undefined iff the
4068
- // capability is absent; the service-level gate remains the enforcement
4069
- // point for aggregated dispatch.
4070
- // #1971 review: same registration-filter discipline as willRename —
4071
- // the server only signed up to hear about matching paths.
4072
- const wireOldUri = oldUri ?? pathToFileURL(oldFilePath).href;
4073
- const wireNewUri = newUri ?? pathToFileURL(newFilePath).href;
4074
- if (!(await fileOperationFiltersMatch(state.fileOperationFilters?.didRename, wireOldUri, wireNewUri, oldFilePath, newFilePath))) {
4075
- recordDegradationOnce({
4076
- kind: "lsp-capability-skip",
4077
- subject: `${state.serverId}:workspace/didRenameFiles:filter`,
4078
- reason: "filter-mismatch",
4079
- });
4080
- return;
4081
- }
4082
- await safeSendNotification(state.connection, "workspace/didRenameFiles", {
4083
- files: [
4084
- {
4085
- oldUri: wireOldUri,
4086
- newUri: wireNewUri,
4087
- },
4088
- ],
4089
- });
4090
- },
4091
- async implementation(filePath, line, character) {
4092
- const result = await navRequest(state, "textDocument/implementation", {
4093
- textDocument: { uri: pathToFileURL(filePath).href },
4094
- position: await toWirePosition(state, filePath, line, character),
4095
- }, filePath);
4096
- if (!result)
4097
- return [];
4098
- return Array.isArray(result) ? result : [result];
4099
- },
4100
- async prepareCallHierarchy(filePath, line, character) {
4101
- const result = await navRequest(state, "textDocument/prepareCallHierarchy", {
4102
- textDocument: { uri: pathToFileURL(filePath).href },
4103
- position: await toWirePosition(state, filePath, line, character),
4104
- }, filePath);
4105
- if (!result)
4106
- return [];
4107
- return Array.isArray(result) ? result : [result];
4108
- },
4109
- async incomingCalls(item) {
4110
- const result = await navRequest(state, "callHierarchy/incomingCalls", { item });
4111
- return result ?? [];
4112
- },
4113
- async outgoingCalls(item) {
4114
- const result = await navRequest(state, "callHierarchy/outgoingCalls", { item });
4115
- return result ?? [];
4116
- },
4117
- async shutdown(options) {
4118
- return clientShutdown(state, options);
4119
- },
4120
- };
4121
- }
4122
- // Helper to safely send notifications - catches stream destruction
4123
- /**
4124
- * Returns `true` once the notification was actually handed to the transport,
4125
- * `false` when a stream error was swallowed (connection error handlers will
4126
- * update state separately). #1669 review F7: callers that mirror what they
4127
- * just told the server locally (`recordSentContent`) MUST gate on this
4128
- * return value — recording a send that never left the process would desync
4129
- * the mirror from what the server actually has, with nothing to self-heal
4130
- * it (the next Incremental range would be computed against content the
4131
- * server never saw).
4132
- */
4133
- async function safeSendNotification(connection, method, params) {
4134
- try {
4135
- await connection.sendNotification(method, params);
4136
- return true;
4137
- }
4138
- catch (err) {
4139
- if (isStreamError(err)) {
4140
- // Silently ignore - stream was destroyed, connection error handlers will update state
4141
- return false;
4142
- }
4143
- throw err;
4144
- }
4145
- }
4146
- const activeRequestsByConnection = new WeakMap();
4147
- // Helper to safely send requests - catches stream destruction
4148
- async function safeSendRequest(connection, method, params,
4149
- // When provided, aborting the signal cancels the in-flight request via
4150
- // vscode-jsonrpc's CancellationToken → an LSP `$/cancelRequest` notification,
4151
- // so a server stops computing a result the agent has already abandoned (#238
4152
- // Item 1). The rejection that follows is swallowed (treated as `undefined`).
4153
- signal, settlement) {
4154
- // Already abandoned before we even sent — don't bother the server.
4155
- if (signal?.aborted)
4156
- return undefined;
4157
- let tokenSource;
4158
- let onAbort;
4159
- if (signal) {
4160
- tokenSource = new CancellationTokenSource();
4161
- onAbort = () => tokenSource?.cancel();
4162
- signal.addEventListener("abort", onAbort, { once: true });
4163
- }
4164
- // Only pass a token when cancellation is wired, so the call shape is unchanged
4165
- // for the (many) requests without a signal.
4166
- const send = () => tokenSource
4167
- ? connection.sendRequest(method, params, tokenSource.token)
4168
- : connection.sendRequest(method, params);
4169
- activeRequestsByConnection.set(connection, (activeRequestsByConnection.get(connection) ?? 0) + 1);
4170
- try {
4171
- // One safe retry on ContentModified (-32801): the document changed under
4172
- // us, so the server discarded the request. A single retry beats returning
4173
- // empty — correctness-under-edit is pi-lens's whole hot path (#238 Item 2).
4174
- const MAX_ATTEMPTS = 2;
4175
- for (let attempt = 1;; attempt++) {
4176
- try {
4177
- return (await send());
4178
- }
4179
- catch (err) {
4180
- if (isCancellationError(err)) {
4181
- if (settlement)
4182
- settlement.cancelled = true;
4183
- return undefined;
4184
- }
4185
- if (isStreamError(err)) {
4186
- // Stream destroyed; connection handlers update client state separately.
4187
- return undefined;
4188
- }
4189
- if (isContentModifiedError(err)) {
4190
- // Retry once (unless we've since been aborted); if it's still
4191
- // ContentModified after that, return empty rather than throwing a
4192
- // code callers don't understand. RequestFailed (-32803) and other
4193
- // codes are permanent and fall through to the rethrow below.
4194
- if (attempt < MAX_ATTEMPTS && !signal?.aborted)
4195
- continue;
4196
- return undefined;
4197
- }
4198
- throw err;
4199
- }
4200
- }
4201
- }
4202
- finally {
4203
- const remaining = (activeRequestsByConnection.get(connection) ?? 1) - 1;
4204
- if (remaining > 0)
4205
- activeRequestsByConnection.set(connection, remaining);
4206
- else
4207
- activeRequestsByConnection.delete(connection);
4208
- if (signal && onAbort)
4209
- signal.removeEventListener("abort", onAbort);
4210
- tokenSource?.dispose();
4211
- }
4212
- }
4213
- // vscode-jsonrpc rejects a token-cancelled request with a `ResponseError` whose
4214
- // code is `RequestCancelled` (-32800) or `ServerCancelled` (-32802). Treat both
4215
- // as "no result" rather than a failure. (isStreamError also matches the
4216
- // "cancelled" message text; this adds the structured error-code path.)
4217
- function isCancellationError(err) {
4218
- const code = err?.code;
4219
- return code === -32800 || code === -32802;
4220
- }
4221
- // `ContentModified` (-32801): the document changed while the request was in
4222
- // flight, so the server couldn't answer against a consistent state. Retryable —
4223
- // the only LSP error code worth a second attempt on the edit hot path (#238).
4224
- function isContentModifiedError(err) {
4225
- return err?.code === -32801;
4226
- }
4227
- // Helper to detect stream destruction / connection disposal errors.
4228
- // vscode-jsonrpc throws these when the LSP server process exits while
4229
- // requests are still in flight:
4230
- // "Connection is disposed."
4231
- // "Pending response rejected since connection got disposed"
4232
- // Neither phrase contains "stream", "destroyed", or "closed", which is
4233
- // why we must also match "disposed" and "cancelled" here.
4234
- function isStreamError(err) {
4235
- if (!(err instanceof Error))
4236
- return false;
4237
- const msg = err.message.toLowerCase();
4238
- return (msg.includes("stream") ||
4239
- msg.includes("destroyed") ||
4240
- msg.includes("closed") ||
4241
- msg.includes("disposed") ||
4242
- msg.includes("cancelled") ||
4243
- err.code === "ERR_STREAM_DESTROYED" ||
4244
- err.code === "ERR_STREAM_WRITE_AFTER_END" ||
4245
- err.code === "EPIPE");
4246
- }
4247
- // Using shared path utilities from path-utils.ts
4248
- function positiveIntFromEnv(name, fallback) {
4249
- const raw = process.env[name];
4250
- if (!raw)
4251
- return fallback;
4252
- const parsed = Number.parseInt(raw, 10);
4253
- if (!Number.isFinite(parsed) || parsed <= 0)
4254
- return fallback;
4255
- return parsed;
4256
- }
4257
- function detectWorkspaceDiagnosticsSupport(initResult) {
4258
- const capabilities = typeof initResult === "object" && initResult !== null
4259
- ? initResult.capabilities
4260
- : undefined;
4261
- const diagnosticProvider = capabilities?.diagnosticProvider;
4262
- if (!diagnosticProvider) {
4263
- return {
4264
- advertised: false,
4265
- mode: "push-only",
4266
- workspaceDiagnostics: false,
4267
- diagnosticProviderKind: "none",
4268
- };
4269
- }
4270
- if (typeof diagnosticProvider === "boolean") {
4271
- return {
4272
- advertised: diagnosticProvider,
4273
- mode: diagnosticProvider ? "pull" : "push-only",
4274
- // The boolean form of diagnosticProvider only signals document pull.
4275
- workspaceDiagnostics: false,
4276
- diagnosticProviderKind: "boolean",
4277
- };
4278
- }
4279
- if (typeof diagnosticProvider === "object") {
4280
- return {
4281
- advertised: true,
4282
- mode: "pull",
4283
- workspaceDiagnostics: diagnosticProvider
4284
- .workspaceDiagnostics === true,
4285
- diagnosticProviderKind: "object",
4286
- };
4287
- }
4288
- return {
4289
- advertised: false,
4290
- mode: "push-only",
4291
- workspaceDiagnostics: false,
4292
- diagnosticProviderKind: typeof diagnosticProvider,
4293
- };
4294
- }
4295
- function detectExecuteCommands(initResult) {
4296
- const capabilities = typeof initResult === "object" && initResult !== null
4297
- ? initResult.capabilities
4298
- : undefined;
4299
- const provider = capabilities?.executeCommandProvider;
4300
- if (typeof provider !== "object" || provider === null)
4301
- return [];
4302
- const commands = provider.commands;
4303
- if (!Array.isArray(commands))
4304
- return [];
4305
- return commands.filter((cmd) => typeof cmd === "string");
4306
- }
4307
- function detectOperationSupport(initResult) {
4308
- const capabilities = typeof initResult === "object" && initResult !== null
4309
- ? initResult.capabilities
4310
- : undefined;
4311
- const hasProvider = (key) => {
4312
- const value = capabilities?.[key];
4313
- if (value === undefined || value === null)
4314
- return false;
4315
- if (typeof value === "boolean")
4316
- return value;
4317
- return isCapabilityRecord(value);
4318
- };
4319
- const codeActionProvider = capabilities?.codeActionProvider;
4320
- const workspace = capabilities?.workspace;
4321
- const fileOperations = isCapabilityRecord(workspace)
4322
- ? workspace.fileOperations
4323
- : undefined;
4324
- return {
4325
- definition: hasProvider("definitionProvider"),
4326
- typeDefinition: hasProvider("typeDefinitionProvider"),
4327
- declaration: hasProvider("declarationProvider"),
4328
- references: hasProvider("referencesProvider"),
4329
- hover: hasProvider("hoverProvider"),
4330
- signatureHelp: hasProvider("signatureHelpProvider"),
4331
- documentSymbol: hasProvider("documentSymbolProvider"),
4332
- workspaceSymbol: hasProvider("workspaceSymbolProvider"),
4333
- codeAction: hasProvider("codeActionProvider"),
4334
- codeActionResolve: isCapabilityRecord(codeActionProvider) &&
4335
- codeActionProvider.resolveProvider === true,
4336
- rename: hasProvider("renameProvider"),
4337
- willRenameFiles: isCapabilityRecord(fileOperations) &&
4338
- isFileOperationRegistrationOptions(fileOperations.willRename),
4339
- didRenameFiles: isCapabilityRecord(fileOperations) &&
4340
- isFileOperationRegistrationOptions(fileOperations.didRename),
4341
- implementation: hasProvider("implementationProvider"),
4342
- callHierarchy: hasProvider("callHierarchyProvider"),
4343
- };
4344
- }
4345
- function isCapabilityRecord(value) {
4346
- return typeof value === "object" && value !== null && !Array.isArray(value);
4347
- }
4348
- function isFileOperationRegistrationOptions(value) {
4349
- return parseFileOperationFilters(value) !== undefined;
4350
- }
4351
- /**
4352
- * Parse the filters of a `FileOperationRegistrationOptions` object. Returns
4353
- * `undefined` when the options are absent or malformed — callers treat that as
4354
- * "no interest declared" and fail closed, never sending the operation.
4355
- */
4356
- function parseFileOperationFilters(value) {
4357
- if (!isCapabilityRecord(value) || !Array.isArray(value.filters)) {
4358
- return undefined;
4359
- }
4360
- const filters = [];
4361
- for (const entry of value.filters) {
4362
- if (!isCapabilityRecord(entry))
4363
- return undefined;
4364
- const pattern = entry.pattern;
4365
- if (!isCapabilityRecord(pattern) ||
4366
- typeof pattern.glob !== "string" ||
4367
- pattern.glob.trim() === "") {
4368
- return undefined;
4369
- }
4370
- if (entry.scheme !== undefined &&
4371
- (typeof entry.scheme !== "string" ||
4372
- entry.scheme.trim() === "" ||
4373
- !/^[A-Za-z][A-Za-z0-9+.-]*$/.test(entry.scheme))) {
4374
- return undefined;
4375
- }
4376
- const matches = pattern.matches;
4377
- if (matches !== undefined && matches !== "file" && matches !== "folder") {
4378
- return undefined;
4379
- }
4380
- if (pattern.options !== undefined && !isCapabilityRecord(pattern.options)) {
4381
- return undefined;
4382
- }
4383
- const options = isCapabilityRecord(pattern.options)
4384
- ? pattern.options
4385
- : undefined;
4386
- if (options?.ignoreCase !== undefined &&
4387
- typeof options.ignoreCase !== "boolean") {
4388
- return undefined;
4389
- }
4390
- filters.push({
4391
- scheme: entry.scheme?.toLowerCase(),
4392
- glob: pattern.glob,
4393
- matches: matches === "file" || matches === "folder" ? matches : undefined,
4394
- ignoreCase: options?.ignoreCase === true,
4395
- });
4396
- }
4397
- return filters;
4398
- }
4399
- /**
4400
- * Whether ANY parsed filter expresses interest in either path of a rename.
4401
- * Conservative by design: unsupported URI schemes, an unresolved explicit
4402
- * file/folder kind, malformed URIs, and zero filters all match nothing.
4403
- */
4404
- async function fileOperationFiltersMatch(filters, oldUri, newUri, oldFilePath, newFilePath) {
4405
- if (!filters || filters.length === 0)
4406
- return false;
4407
- const candidates = [oldUri, newUri].flatMap(fileOperationUriCandidates);
4408
- const entityKind = filters.some((filter) => filter.matches !== undefined)
4409
- ? await probeRenameEntityKind(oldFilePath, newFilePath)
4410
- : undefined;
4411
- for (const filter of filters) {
4412
- if (filter.scheme !== undefined && filter.scheme !== "file")
4413
- continue;
4414
- if (filter.matches !== undefined && filter.matches !== entityKind)
4415
- continue;
4416
- for (const candidate of candidates) {
4417
- try {
4418
- if (minimatch(candidate, filter.glob, {
4419
- nocase: filter.ignoreCase,
4420
- dot: true,
4421
- })) {
4422
- return true;
4423
- }
4424
- }
4425
- catch {
4426
- // Malformed glob implementations must never widen server fan-out.
4427
- }
4428
- }
4429
- }
4430
- return false;
4431
- }
4432
- function fileOperationUriCandidates(uri) {
4433
- let parsed;
4434
- try {
4435
- parsed = new URL(uri);
4436
- }
4437
- catch {
4438
- return [];
4439
- }
4440
- // pi-lens performs local filesystem renames only. Even when a caller supplies
4441
- // preserved URI spelling, another scheme cannot describe that operation.
4442
- if (parsed.protocol.toLowerCase() !== "file:")
4443
- return [];
4444
- let uriPath;
4445
- try {
4446
- uriPath = decodeURIComponent(parsed.pathname).replace(/\\/g, "/");
4447
- }
4448
- catch {
4449
- return [];
4450
- }
4451
- return [uriPath];
4452
- }
4453
- async function probeRenameEntityKind(oldFilePath, newFilePath) {
4454
- for (const filePath of [oldFilePath, newFilePath]) {
4455
- try {
4456
- const info = await lstat(filePath);
4457
- return info.isDirectory() ? "folder" : "file";
4458
- }
4459
- catch {
4460
- // Before the rename the old path normally exists; after it, the new path
4461
- // does. Probe both instead of deriving behavior from the host OS.
4462
- }
4463
- }
4464
- return undefined;
4465
- }