@herbertgao/pi-extensions 2026.10.1 → 2026.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -9
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-audit.md +2 -2
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-debt.md +2 -2
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-gain.md +1 -1
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-help.md +1 -1
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-review.md +2 -2
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail.md +1 -1
- package/node_modules/@dietrichgebert/ponytail/.qoder/rules/ponytail.md +21 -21
- package/node_modules/@dietrichgebert/ponytail/.qoder-plugin/plugin.json +1 -1
- package/node_modules/@dietrichgebert/ponytail/AGENTS.md +21 -21
- package/node_modules/@dietrichgebert/ponytail/README.md +68 -72
- package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-activate.js +7 -0
- package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-instructions.js +30 -30
- package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-map.js +103 -0
- package/node_modules/@dietrichgebert/ponytail/package.json +2 -2
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail/SKILL.md +33 -101
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail-audit/SKILL.md +89 -32
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail-debt/SKILL.md +13 -12
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail-gain/SKILL.md +17 -16
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail-help/SKILL.md +9 -10
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail-review/SKILL.md +96 -57
- package/node_modules/@herbertgao/pi-agency/LICENSE +21 -0
- package/node_modules/@herbertgao/pi-agency/README.md +40 -0
- package/node_modules/@herbertgao/pi-agency/package.json +53 -0
- package/node_modules/@herbertgao/pi-agency/src/index.ts +441 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +12 -12
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +30 -6
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +15 -9
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +27 -9
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -1
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +2 -2
- package/node_modules/@narumitw/pi-caffeinate/package.json +3 -3
- package/node_modules/@tifan/pi-mermaid-open/package.json +5 -1
- package/node_modules/@tifan/pi-mermaid-open/src/index.ts +26 -9
- package/node_modules/@tifan/pi-recap/package.json +1 -1
- package/node_modules/@tifan/pi-recap/src/index.ts +104 -20
- package/node_modules/@tifan/pi-recap/src/models.ts +46 -4
- package/node_modules/pi-antigravity/README.md +23 -2
- package/node_modules/pi-antigravity/package.json +5 -3
- package/node_modules/pi-antigravity/src/diagnostics/diagnostics.ts +3 -2
- package/node_modules/pi-antigravity/src/features/generate-image.ts +72 -0
- package/node_modules/pi-antigravity/src/features/google-search.ts +71 -0
- package/node_modules/pi-antigravity/src/image/image.ts +86 -47
- package/node_modules/pi-antigravity/src/image/native.ts +69 -0
- package/node_modules/pi-antigravity/src/index.ts +52 -137
- package/node_modules/pi-antigravity/src/search/search.ts +130 -31
- package/node_modules/pi-antigravity/src/stream/stream.ts +162 -113
- package/node_modules/pi-antigravity/src/types/enums.ts +1 -0
- package/node_modules/pi-antigravity/src/types/types.ts +1 -1
- package/node_modules/pi-antigravity/src/utils/security.ts +47 -1
- package/node_modules/pi-antigravity/src/utils/util.ts +38 -6
- package/node_modules/pi-lens/CHANGELOG.md +1064 -0
- package/node_modules/pi-lens/README.md +115 -25
- package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +6 -2
- package/node_modules/pi-lens/dist/chunk-22AVIGXS.js +560 -0
- package/node_modules/pi-lens/dist/chunk-27BBHSZC.js +106 -0
- package/node_modules/pi-lens/dist/chunk-2BEMIWEW.js +51 -0
- package/node_modules/pi-lens/dist/chunk-2EECBNC5.js +89 -0
- package/node_modules/pi-lens/dist/chunk-34U7PLGE.js +23 -0
- package/node_modules/pi-lens/dist/chunk-3I5QFT3N.js +181 -0
- package/node_modules/pi-lens/dist/chunk-3MED75HT.js +37 -0
- package/node_modules/pi-lens/dist/chunk-47D2BO4S.js +87 -0
- package/node_modules/pi-lens/dist/chunk-4RERDEXR.js +348 -0
- package/node_modules/pi-lens/dist/chunk-4W2PLC4J.js +27 -0
- package/node_modules/pi-lens/dist/chunk-52H5B5TC.js +202 -0
- package/node_modules/pi-lens/dist/chunk-54B42YRX.js +27 -0
- package/node_modules/pi-lens/dist/chunk-5RXBNSW2.js +266 -0
- package/node_modules/pi-lens/dist/chunk-5THXD6X5.js +201 -0
- package/node_modules/pi-lens/dist/chunk-5W3R6LFT.js +24 -0
- package/node_modules/pi-lens/dist/chunk-5ZEJHV35.js +341 -0
- package/node_modules/pi-lens/dist/chunk-6P62QVEA.js +118 -0
- package/node_modules/pi-lens/dist/chunk-6QIUX4EP.js +459 -0
- package/node_modules/pi-lens/dist/chunk-6VCD4VKZ.js +78 -0
- package/node_modules/pi-lens/dist/chunk-7F72PXY2.js +326 -0
- package/node_modules/pi-lens/dist/chunk-7FBF7NPR.js +998 -0
- package/node_modules/pi-lens/dist/chunk-7U2IDNXN.js +37 -0
- package/node_modules/pi-lens/dist/chunk-ABBOA7UD.js +157 -0
- package/node_modules/pi-lens/dist/chunk-AHDW3GPE.js +50 -0
- package/node_modules/pi-lens/dist/chunk-AJ3QXTRQ.js +756 -0
- package/node_modules/pi-lens/dist/chunk-AJVLWNFN.js +1610 -0
- package/node_modules/pi-lens/dist/chunk-AJXODZY6.js +112 -0
- package/node_modules/pi-lens/dist/chunk-ARFCXG7S.js +84 -0
- package/node_modules/pi-lens/dist/chunk-AS44CFQ4.js +708 -0
- package/node_modules/pi-lens/dist/chunk-AWE5TMPO.js +38 -0
- package/node_modules/pi-lens/dist/chunk-B3I2UUT4.js +4692 -0
- package/node_modules/pi-lens/dist/chunk-BRJKOBZP.js +331 -0
- package/node_modules/pi-lens/dist/chunk-BW5KFIQC.js +59 -0
- package/node_modules/pi-lens/dist/chunk-C2AUYXQH.js +149 -0
- package/node_modules/pi-lens/dist/chunk-CGGAIP7J.js +29 -0
- package/node_modules/pi-lens/dist/chunk-D6QS3U6M.js +30 -0
- package/node_modules/pi-lens/dist/chunk-DFVMIKMQ.js +349 -0
- package/node_modules/pi-lens/dist/chunk-DIULIWKU.js +169 -0
- package/node_modules/pi-lens/dist/chunk-DQ2NZ7C4.js +2317 -0
- package/node_modules/pi-lens/dist/chunk-E2WFBRHM.js +213 -0
- package/node_modules/pi-lens/dist/chunk-E4MAEDPM.js +50 -0
- package/node_modules/pi-lens/dist/chunk-EOKRQ3SA.js +30 -0
- package/node_modules/pi-lens/dist/chunk-F3AU2TMM.js +2626 -0
- package/node_modules/pi-lens/dist/chunk-F3P7VQ7H.js +170 -0
- package/node_modules/pi-lens/dist/chunk-FC4RP4I2.js +12 -0
- package/node_modules/pi-lens/dist/chunk-FJNKANOS.js +32 -0
- package/node_modules/pi-lens/dist/chunk-GUOGVTCQ.js +146 -0
- package/node_modules/pi-lens/dist/chunk-GVXA6CCE.js +18 -0
- package/node_modules/pi-lens/dist/chunk-HHYAELLJ.js +2334 -0
- package/node_modules/pi-lens/dist/chunk-HMFXILPV.js +83 -0
- package/node_modules/pi-lens/dist/chunk-HVUZXSWK.js +421 -0
- package/node_modules/pi-lens/dist/chunk-HWBZIGMJ.js +62 -0
- package/node_modules/pi-lens/dist/chunk-I3R7GKDR.js +17 -0
- package/node_modules/pi-lens/dist/chunk-I7SPGOWI.js +55 -0
- package/node_modules/pi-lens/dist/chunk-IIXOAGHR.js +38 -0
- package/node_modules/pi-lens/dist/chunk-IVP5KAB7.js +2250 -0
- package/node_modules/pi-lens/dist/chunk-IXEBJSUE.js +22 -0
- package/node_modules/pi-lens/dist/chunk-JOLITGCC.js +27 -0
- package/node_modules/pi-lens/dist/chunk-K6VYLB2U.js +145 -0
- package/node_modules/pi-lens/dist/chunk-LJDODBBZ.js +78 -0
- package/node_modules/pi-lens/dist/chunk-LNAKEO63.js +78 -0
- package/node_modules/pi-lens/dist/chunk-LSMJ6KBE.js +4139 -0
- package/node_modules/pi-lens/dist/chunk-LW6XKDZQ.js +5565 -0
- package/node_modules/pi-lens/dist/chunk-N3YQJI6O.js +899 -0
- package/node_modules/pi-lens/dist/chunk-NABLC7W3.js +148 -0
- package/node_modules/pi-lens/dist/chunk-NKOUFPQF.js +659 -0
- package/node_modules/pi-lens/dist/chunk-NL5GLMFI.js +211 -0
- package/node_modules/pi-lens/dist/chunk-NMABWBZN.js +327 -0
- package/node_modules/pi-lens/dist/chunk-NTHH2J74.js +27 -0
- package/node_modules/pi-lens/dist/chunk-NTUJEGDZ.js +59 -0
- package/node_modules/pi-lens/dist/chunk-NXL4FFQQ.js +47 -0
- package/node_modules/pi-lens/dist/chunk-NXPS2L6U.js +466 -0
- package/node_modules/pi-lens/dist/chunk-O5WILGMI.js +42 -0
- package/node_modules/pi-lens/dist/chunk-O6TQT6RI.js +48 -0
- package/node_modules/pi-lens/dist/chunk-OAXNDZJ7.js +323 -0
- package/node_modules/pi-lens/dist/chunk-ODEEOXPH.js +44 -0
- package/node_modules/pi-lens/dist/chunk-OQGFCZJ4.js +4533 -0
- package/node_modules/pi-lens/dist/chunk-ORTMY4Q4.js +282 -0
- package/node_modules/pi-lens/dist/chunk-OS32RNR2.js +442 -0
- package/node_modules/pi-lens/dist/chunk-PAK6IDME.js +189 -0
- package/node_modules/pi-lens/dist/chunk-PWFHBTSF.js +406 -0
- package/node_modules/pi-lens/dist/chunk-Q5U6FMDI.js +2352 -0
- package/node_modules/pi-lens/dist/chunk-QOZXJ4NA.js +3364 -0
- package/node_modules/pi-lens/dist/chunk-QYF5S6P6.js +45 -0
- package/node_modules/pi-lens/dist/chunk-RIISMHKN.js +36 -0
- package/node_modules/pi-lens/dist/chunk-RPD4ZSB7.js +25 -0
- package/node_modules/pi-lens/dist/chunk-RPD5K5NC.js +79 -0
- package/node_modules/pi-lens/dist/chunk-RUOG5D2C.js +262 -0
- package/node_modules/pi-lens/dist/chunk-T4ERT73Y.js +599 -0
- package/node_modules/pi-lens/dist/chunk-T6D4Q4AQ.js +54 -0
- package/node_modules/pi-lens/dist/chunk-TGPXLY2J.js +391 -0
- package/node_modules/pi-lens/dist/chunk-TLGFBM3F.js +12116 -0
- package/node_modules/pi-lens/dist/chunk-TO6WL3GM.js +88 -0
- package/node_modules/pi-lens/dist/chunk-TT22N74J.js +517 -0
- package/node_modules/pi-lens/dist/chunk-TVN5N6GY.js +21216 -0
- package/node_modules/pi-lens/dist/chunk-U3VGR4VU.js +308 -0
- package/node_modules/pi-lens/dist/chunk-UK3CMEAL.js +1250 -0
- package/node_modules/pi-lens/dist/chunk-UOPVJQ4N.js +103 -0
- package/node_modules/pi-lens/dist/chunk-UXVNL3D7.js +23 -0
- package/node_modules/pi-lens/dist/chunk-UY752QSN.js +45 -0
- package/node_modules/pi-lens/dist/chunk-V5PXFSZK.js +188 -0
- package/node_modules/pi-lens/dist/chunk-V7R425XG.js +398 -0
- package/node_modules/pi-lens/dist/chunk-VCSRSYLF.js +81 -0
- package/node_modules/pi-lens/dist/chunk-VD4JQCQC.js +162 -0
- package/node_modules/pi-lens/dist/chunk-VDBPVIKD.js +62 -0
- package/node_modules/pi-lens/dist/chunk-VHQLPIRJ.js +114 -0
- package/node_modules/pi-lens/dist/chunk-VN2AXLEX.js +5414 -0
- package/node_modules/pi-lens/dist/chunk-VNMT7C64.js +478 -0
- package/node_modules/pi-lens/dist/chunk-WONERJTP.js +309 -0
- package/node_modules/pi-lens/dist/chunk-XECLWQZN.js +85 -0
- package/node_modules/pi-lens/dist/chunk-XIMYZ3JA.js +27 -0
- package/node_modules/pi-lens/dist/chunk-XK4CIILI.js +144 -0
- package/node_modules/pi-lens/dist/chunk-Y4T27COV.js +562 -0
- package/node_modules/pi-lens/dist/chunk-YGVYKNSN.js +1860 -0
- package/node_modules/pi-lens/dist/chunk-YLF3PPT5.js +308 -0
- package/node_modules/pi-lens/dist/chunk-YSNFMJKN.js +14 -0
- package/node_modules/pi-lens/dist/chunk-Z3CCVDUB.js +76 -0
- package/node_modules/pi-lens/dist/chunk-Z54ZSOBZ.js +61 -0
- package/node_modules/pi-lens/dist/chunk-ZAWW3AF6.js +97 -0
- package/node_modules/pi-lens/dist/chunk-ZMOL6RQC.js +167 -0
- package/node_modules/pi-lens/dist/index.js +16456 -8335
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +186 -232
- package/node_modules/pi-lens/dist/mcp/cli.js +137 -98
- package/node_modules/pi-lens/dist/mcp/server.js +22589 -1573
- package/node_modules/pi-lens/dist/mcp/worker.js +97 -38
- package/node_modules/pi-lens/dist/probes/installer.js +135 -0
- package/node_modules/pi-lens/dist/workers/project-snapshot-persist-worker.js +22 -0
- package/node_modules/pi-lens/dist/workers/review-graph-persist-worker.js +15 -0
- package/node_modules/pi-lens/docs/agent-guide.md +13 -4
- package/node_modules/pi-lens/docs/agent-tools.md +42 -0
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +3 -2
- package/node_modules/pi-lens/docs/configuration.md +20 -1
- package/node_modules/pi-lens/docs/environment-variables.md +52 -2
- package/node_modules/pi-lens/docs/features.md +8 -4
- package/node_modules/pi-lens/docs/globalconfig.md +1 -0
- package/node_modules/pi-lens/docs/io-bridge-v2-migration.md +94 -0
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +36 -4
- package/node_modules/pi-lens/docs/lsp-idle-eviction.md +124 -0
- package/node_modules/pi-lens/docs/mcp.md +21 -5
- package/node_modules/pi-lens/docs/merge-queue-rollout.md +77 -0
- package/node_modules/pi-lens/docs/pi-lens-fixer.md +156 -88
- package/node_modules/pi-lens/docs/pi-lens-investigator.md +74 -26
- package/node_modules/pi-lens/docs/pi-lens-merge-policy.md +694 -0
- package/node_modules/pi-lens/docs/pi-lens-monitor.md +73 -7
- package/node_modules/pi-lens/docs/pi-lens-retro.md +6 -4
- package/node_modules/pi-lens/docs/pi-lens-reviewer.md +188 -48
- package/node_modules/pi-lens/docs/pi-lens-subagent.md +246 -45
- package/node_modules/pi-lens/docs/pi-lens-warden.md +18 -7
- package/node_modules/pi-lens/docs/public-api-stability.md +55 -0
- package/node_modules/pi-lens/docs/release-qa-baseline.md +84 -17
- package/node_modules/pi-lens/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
- package/node_modules/pi-lens/docs/servercapabilities.md +53 -49
- package/node_modules/pi-lens/docs/settings.md +1 -0
- package/node_modules/pi-lens/docs/usage.md +5 -1
- package/node_modules/pi-lens/grammars/tree-sitter-bash.wasm +0 -0
- package/node_modules/pi-lens/grammars/tree-sitter-bash.wasm.json +3 -3
- package/node_modules/pi-lens/package.json +18 -6
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +1275 -62
- package/node_modules/pi-lens/scripts/download-grammars.js +5 -0
- package/node_modules/pi-lens/scripts/grammars.lock.json +6 -1
- package/node_modules/pi-lens/scripts/install-selftest.mjs +33 -34
- package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +13 -4
- package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +18 -0
- package/package.json +15 -10
- package/node_modules/@dietrichgebert/ponytail/README.es.md +0 -212
- package/node_modules/@dietrichgebert/ponytail/README.ja.md +0 -212
- package/node_modules/@dietrichgebert/ponytail/README.ko.md +0 -212
- package/node_modules/@dietrichgebert/ponytail/README.zh-CN.md +0 -212
- package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +0 -35
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +0 -1610
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +0 -593
- package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +0 -147
- package/node_modules/pi-lens/dist/clients/agent-nudge.js +0 -325
- package/node_modules/pi-lens/dist/clients/analysed-root.js +0 -1
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +0 -731
- package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +0 -146
- package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +0 -118
- package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -10
- package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +0 -154
- package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +0 -75
- package/node_modules/pi-lens/dist/clients/atomic-write.js +0 -189
- package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +0 -12
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +0 -1056
- package/node_modules/pi-lens/dist/clients/biome-client.js +0 -357
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +0 -837
- package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +0 -129
- package/node_modules/pi-lens/dist/clients/bootstrap.js +0 -569
- package/node_modules/pi-lens/dist/clients/bounded-cache.js +0 -190
- package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +0 -202
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +0 -295
- package/node_modules/pi-lens/dist/clients/build-identity.js +0 -95
- package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +0 -113
- package/node_modules/pi-lens/dist/clients/bus-events-logger.js +0 -105
- package/node_modules/pi-lens/dist/clients/bus-publish.js +0 -171
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +0 -190
- package/node_modules/pi-lens/dist/clients/cache-manager.js +0 -524
- package/node_modules/pi-lens/dist/clients/cache-observability.js +0 -1313
- package/node_modules/pi-lens/dist/clients/call-graph.js +0 -852
- package/node_modules/pi-lens/dist/clients/cargo-manifest.js +0 -422
- package/node_modules/pi-lens/dist/clients/cascade-budget.js +0 -196
- package/node_modules/pi-lens/dist/clients/cascade-format.js +0 -315
- package/node_modules/pi-lens/dist/clients/cascade-logger.js +0 -31
- package/node_modules/pi-lens/dist/clients/cascade-types.js +0 -9
- package/node_modules/pi-lens/dist/clients/child-unref.js +0 -124
- package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +0 -286
- package/node_modules/pi-lens/dist/clients/codebase-model.js +0 -173
- package/node_modules/pi-lens/dist/clients/collateral-test-role.js +0 -40
- package/node_modules/pi-lens/dist/clients/complexity-client.js +0 -462
- package/node_modules/pi-lens/dist/clients/config-core/deny.js +0 -221
- package/node_modules/pi-lens/dist/clients/config-core/index.js +0 -50
- package/node_modules/pi-lens/dist/clients/config-core/merge.js +0 -357
- package/node_modules/pi-lens/dist/clients/config-core/normalize.js +0 -340
- package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +0 -248
- package/node_modules/pi-lens/dist/clients/config-core/provenance.js +0 -164
- package/node_modules/pi-lens/dist/clients/config-core/records.js +0 -218
- package/node_modules/pi-lens/dist/clients/config-core/resolve.js +0 -125
- package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +0 -78
- package/node_modules/pi-lens/dist/clients/config-core/schema.js +0 -165
- package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +0 -285
- package/node_modules/pi-lens/dist/clients/config-locations.js +0 -388
- package/node_modules/pi-lens/dist/clients/config-resolve.js +0 -798
- package/node_modules/pi-lens/dist/clients/config-schema.js +0 -251
- package/node_modules/pi-lens/dist/clients/config-warn.js +0 -407
- package/node_modules/pi-lens/dist/clients/console-guard-install.js +0 -26
- package/node_modules/pi-lens/dist/clients/cooperative-budget.js +0 -42
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +0 -510
- package/node_modules/pi-lens/dist/clients/dead-code-logger.js +0 -30
- package/node_modules/pi-lens/dist/clients/deadline-utils.js +0 -280
- package/node_modules/pi-lens/dist/clients/debug-handles.js +0 -211
- package/node_modules/pi-lens/dist/clients/debug-heap.js +0 -174
- package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +0 -106
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +0 -426
- package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +0 -169
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +0 -885
- package/node_modules/pi-lens/dist/clients/deps/ast-grep-napi.js +0 -22
- package/node_modules/pi-lens/dist/clients/deps/js-yaml.js +0 -9
- package/node_modules/pi-lens/dist/clients/deps/minimatch.js +0 -4
- package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +0 -14
- package/node_modules/pi-lens/dist/clients/deps/typebox.js +0 -14
- package/node_modules/pi-lens/dist/clients/deps/vscode-jsonrpc.js +0 -1
- package/node_modules/pi-lens/dist/clients/deps/web-tree-sitter.js +0 -24
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +0 -630
- package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +0 -305
- package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +0 -73
- package/node_modules/pi-lens/dist/clients/diagnostic-tracker.js +0 -105
- package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +0 -230
- package/node_modules/pi-lens/dist/clients/dispatch/ast-grep-catalog.js +0 -65
- package/node_modules/pi-lens/dist/clients/dispatch/auxiliary-lsp.js +0 -343
- package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +0 -35
- package/node_modules/pi-lens/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +0 -1269
- package/node_modules/pi-lens/dist/clients/dispatch/fact-provider-types.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/fact-rule-runner.js +0 -17
- package/node_modules/pi-lens/dist/clients/dispatch/fact-runner.js +0 -30
- package/node_modules/pi-lens/dist/clients/dispatch/fact-scheduler.js +0 -68
- package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +0 -377
- package/node_modules/pi-lens/dist/clients/dispatch/facts/comment-facts.js +0 -22
- package/node_modules/pi-lens/dist/clients/dispatch/facts/file-content.js +0 -19
- package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +0 -348
- package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +0 -204
- package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
- package/node_modules/pi-lens/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
- package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +0 -339
- package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +0 -417
- package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +0 -89
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +0 -2377
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +0 -11
- package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +0 -87
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +0 -278
- package/node_modules/pi-lens/dist/clients/dispatch/priorities.js +0 -20
- package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +0 -50
- package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +0 -69
- package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +0 -135
- package/node_modules/pi-lens/dist/clients/dispatch/rules/async-noise.js +0 -40
- package/node_modules/pi-lens/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
- package/node_modules/pi-lens/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
- package/node_modules/pi-lens/dist/clients/dispatch/rules/error-obscuring.js +0 -32
- package/node_modules/pi-lens/dist/clients/dispatch/rules/error-swallowing.js +0 -31
- package/node_modules/pi-lens/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
- package/node_modules/pi-lens/dist/clients/dispatch/rules/high-complexity.js +0 -72
- package/node_modules/pi-lens/dist/clients/dispatch/rules/high-fan-out.js +0 -74
- package/node_modules/pi-lens/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
- package/node_modules/pi-lens/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
- package/node_modules/pi-lens/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
- package/node_modules/pi-lens/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
- package/node_modules/pi-lens/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
- package/node_modules/pi-lens/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
- package/node_modules/pi-lens/dist/clients/dispatch/runner-context.js +0 -52
- package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -111
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -296
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +0 -219
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -82
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -416
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -183
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -176
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -164
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -195
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -137
- package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -29
- package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -69
- package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -167
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +0 -104
- package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -135
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -91
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -171
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +0 -940
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -117
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +0 -108
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -99
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -110
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -379
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -167
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -102
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +0 -414
- package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -85
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -120
- package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -62
- package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +0 -169
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -99
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +0 -88
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +0 -256
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +0 -173
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -102
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +0 -128
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -259
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -183
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -166
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -163
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -174
- package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -168
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -125
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -643
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -253
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -95
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils.js +0 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -128
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +0 -95
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -79
- package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +0 -79
- package/node_modules/pi-lens/dist/clients/dispatch/tool-profile.js +0 -30
- package/node_modules/pi-lens/dist/clients/dispatch/types.js +0 -21
- package/node_modules/pi-lens/dist/clients/dispatch/utils/format-utils.js +0 -45
- package/node_modules/pi-lens/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
- package/node_modules/pi-lens/dist/clients/disposition-logger.js +0 -32
- package/node_modules/pi-lens/dist/clients/disposition-publish.js +0 -124
- package/node_modules/pi-lens/dist/clients/durable-store.js +0 -97
- package/node_modules/pi-lens/dist/clients/effective-config.js +0 -415
- package/node_modules/pi-lens/dist/clients/env-utils.js +0 -83
- package/node_modules/pi-lens/dist/clients/error-class.js +0 -23
- package/node_modules/pi-lens/dist/clients/eval-timestamp.js +0 -17
- package/node_modules/pi-lens/dist/clients/event-loop-hold.js +0 -274
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +0 -250
- package/node_modules/pi-lens/dist/clients/extension-log.js +0 -504
- package/node_modules/pi-lens/dist/clients/extension-mode.js +0 -73
- package/node_modules/pi-lens/dist/clients/feature-hints.js +0 -103
- package/node_modules/pi-lens/dist/clients/file-kinds.js +0 -412
- package/node_modules/pi-lens/dist/clients/file-role.js +0 -134
- package/node_modules/pi-lens/dist/clients/file-time.js +0 -160
- package/node_modules/pi-lens/dist/clients/file-utils.js +0 -1032
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +0 -494
- package/node_modules/pi-lens/dist/clients/finding-identity.js +0 -88
- package/node_modules/pi-lens/dist/clients/fix-worklog.js +0 -103
- package/node_modules/pi-lens/dist/clients/format-events-publish.js +0 -343
- package/node_modules/pi-lens/dist/clients/format-service.js +0 -222
- package/node_modules/pi-lens/dist/clients/formatters-lazy.js +0 -9
- package/node_modules/pi-lens/dist/clients/formatters.js +0 -2032
- package/node_modules/pi-lens/dist/clients/freshness-cadence.js +0 -17
- package/node_modules/pi-lens/dist/clients/freshness.js +0 -38
- package/node_modules/pi-lens/dist/clients/generated-artifacts.js +0 -394
- package/node_modules/pi-lens/dist/clients/generation-guard.js +0 -215
- package/node_modules/pi-lens/dist/clients/git-guard.js +0 -1136
- package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +0 -243
- package/node_modules/pi-lens/dist/clients/gitleaks-client.js +0 -509
- package/node_modules/pi-lens/dist/clients/go-client.js +0 -104
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +0 -605
- package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +0 -348
- package/node_modules/pi-lens/dist/clients/grammar-source.js +0 -465
- package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +0 -135
- package/node_modules/pi-lens/dist/clients/hashline-anchor.js +0 -424
- package/node_modules/pi-lens/dist/clients/hook-budgets.js +0 -76
- package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +0 -140
- package/node_modules/pi-lens/dist/clients/host-ports.js +0 -39
- package/node_modules/pi-lens/dist/clients/indent-retarget.js +0 -154
- package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +0 -186
- package/node_modules/pi-lens/dist/clients/install-diagnostics.js +0 -156
- package/node_modules/pi-lens/dist/clients/installer/index.js +0 -5416
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +0 -58
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +0 -783
- package/node_modules/pi-lens/dist/clients/instance-reaper-state.js +0 -7
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +0 -1137
- package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +0 -145
- package/node_modules/pi-lens/dist/clients/instance-registry.js +0 -773
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +0 -378
- package/node_modules/pi-lens/dist/clients/json-cache-read.js +0 -65
- package/node_modules/pi-lens/dist/clients/knip-client.js +0 -788
- package/node_modules/pi-lens/dist/clients/language-policy.js +0 -252
- package/node_modules/pi-lens/dist/clients/language-profile.js +0 -361
- package/node_modules/pi-lens/dist/clients/language-registry.js +0 -603
- package/node_modules/pi-lens/dist/clients/latency-logger.js +0 -727
- package/node_modules/pi-lens/dist/clients/lazy-import.js +0 -21
- package/node_modules/pi-lens/dist/clients/ledger-bounds.js +0 -54
- package/node_modules/pi-lens/dist/clients/lens-config.js +0 -404
- package/node_modules/pi-lens/dist/clients/lens-engine.js +0 -358
- package/node_modules/pi-lens/dist/clients/lens-events.js +0 -125
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +0 -467
- package/node_modules/pi-lens/dist/clients/lens-map.js +0 -1156
- package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +0 -74
- package/node_modules/pi-lens/dist/clients/log-cleanup.js +0 -222
- package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +0 -178
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +0 -421
- package/node_modules/pi-lens/dist/clients/lsp/client.js +0 -4593
- package/node_modules/pi-lens/dist/clients/lsp/config.js +0 -691
- package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +0 -41
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +0 -225
- package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +0 -467
- package/node_modules/pi-lens/dist/clients/lsp/edits.js +0 -1219
- package/node_modules/pi-lens/dist/clients/lsp/index.js +0 -8305
- package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +0 -216
- package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +0 -298
- package/node_modules/pi-lens/dist/clients/lsp/language.js +0 -63
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +0 -746
- package/node_modules/pi-lens/dist/clients/lsp/lombok.js +0 -158
- package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +0 -5
- package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +0 -251
- package/node_modules/pi-lens/dist/clients/lsp/position-encoding.js +0 -61
- package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +0 -83
- package/node_modules/pi-lens/dist/clients/lsp/server.js +0 -3290
- package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +0 -192
- package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +0 -17
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +0 -56
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +0 -332
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +0 -85
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/index.js +0 -10
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +0 -392
- package/node_modules/pi-lens/dist/clients/lsp/watch-queue.js +0 -61
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
- package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +0 -184
- package/node_modules/pi-lens/dist/clients/lsp-budget.js +0 -186
- package/node_modules/pi-lens/dist/clients/lsp-document-symbols.js +0 -110
- package/node_modules/pi-lens/dist/clients/lsp-lazy.js +0 -9
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +0 -396
- package/node_modules/pi-lens/dist/clients/lsp-status.js +0 -59
- package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +0 -40
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +0 -420
- package/node_modules/pi-lens/dist/clients/mcp/host-shim.js +0 -31
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +0 -523
- package/node_modules/pi-lens/dist/clients/mcp/review.js +0 -197
- package/node_modules/pi-lens/dist/clients/mcp/session.js +0 -412
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +0 -208
- package/node_modules/pi-lens/dist/clients/message-end-attribution.js +0 -30
- package/node_modules/pi-lens/dist/clients/metrics-client.js +0 -107
- package/node_modules/pi-lens/dist/clients/metrics-history.js +0 -327
- package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +0 -263
- package/node_modules/pi-lens/dist/clients/model-provider.js +0 -53
- package/node_modules/pi-lens/dist/clients/module-report-lsp.js +0 -189
- package/node_modules/pi-lens/dist/clients/module-report.js +0 -2150
- package/node_modules/pi-lens/dist/clients/mutating-tool.js +0 -651
- package/node_modules/pi-lens/dist/clients/mutation-attribution.js +0 -368
- package/node_modules/pi-lens/dist/clients/mutation-bridge.js +0 -240
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +0 -734
- package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +0 -101
- package/node_modules/pi-lens/dist/clients/observed-mutation.js +0 -1253
- package/node_modules/pi-lens/dist/clients/oldtext-autopatch.js +0 -128
- package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +0 -496
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +0 -333
- package/node_modules/pi-lens/dist/clients/opengrep-config.js +0 -76
- package/node_modules/pi-lens/dist/clients/package-manager.js +0 -607
- package/node_modules/pi-lens/dist/clients/package-root.js +0 -38
- package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +0 -439
- package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +0 -43
- package/node_modules/pi-lens/dist/clients/path-keyed-map.js +0 -108
- package/node_modules/pi-lens/dist/clients/path-utils.js +0 -1244
- package/node_modules/pi-lens/dist/clients/performance-report.js +0 -331
- package/node_modules/pi-lens/dist/clients/persist-debounce.js +0 -70
- package/node_modules/pi-lens/dist/clients/persistent-reverify.js +0 -271
- package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +0 -119
- package/node_modules/pi-lens/dist/clients/pipeline.js +0 -1480
- package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +0 -37
- package/node_modules/pi-lens/dist/clients/print-mode.js +0 -21
- package/node_modules/pi-lens/dist/clients/probe-home-state.js +0 -228
- package/node_modules/pi-lens/dist/clients/process-bridge.js +0 -66
- package/node_modules/pi-lens/dist/clients/process-singletons.js +0 -210
- package/node_modules/pi-lens/dist/clients/process-snapshot.js +0 -68
- package/node_modules/pi-lens/dist/clients/project-changes.js +0 -181
- package/node_modules/pi-lens/dist/clients/project-conventions.js +0 -171
- package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +0 -137
- package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +0 -109
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +0 -668
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +0 -520
- package/node_modules/pi-lens/dist/clients/project-diagnostics/types.js +0 -1
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +0 -807
- package/node_modules/pi-lens/dist/clients/project-report.js +0 -701
- package/node_modules/pi-lens/dist/clients/project-scale.js +0 -237
- package/node_modules/pi-lens/dist/clients/project-scan-policy.js +0 -11
- package/node_modules/pi-lens/dist/clients/project-snapshot-fingerprint.js +0 -48
- package/node_modules/pi-lens/dist/clients/project-snapshot-persist-worker.js +0 -20
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +0 -1653
- package/node_modules/pi-lens/dist/clients/project-trust.js +0 -181
- package/node_modules/pi-lens/dist/clients/python-environment.js +0 -217
- package/node_modules/pi-lens/dist/clients/python-provenance.js +0 -639
- package/node_modules/pi-lens/dist/clients/quiet-window-config.js +0 -47
- package/node_modules/pi-lens/dist/clients/quiet-window.js +0 -236
- package/node_modules/pi-lens/dist/clients/read-bridge.js +0 -139
- package/node_modules/pi-lens/dist/clients/read-expansion.js +0 -266
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +0 -251
- package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +0 -1306
- package/node_modules/pi-lens/dist/clients/read-guard.js +0 -1561
- package/node_modules/pi-lens/dist/clients/recent-touches.js +0 -240
- package/node_modules/pi-lens/dist/clients/redact/secrets.js +0 -449
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +0 -628
- package/node_modules/pi-lens/dist/clients/reverse-deps.js +0 -270
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +0 -5126
- package/node_modules/pi-lens/dist/clients/review-graph/format.js +0 -33
- package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +0 -163
- package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +0 -481
- package/node_modules/pi-lens/dist/clients/review-graph/persist-worker.js +0 -10
- package/node_modules/pi-lens/dist/clients/review-graph/query.js +0 -244
- package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +0 -21
- package/node_modules/pi-lens/dist/clients/review-graph/service.js +0 -96
- package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +0 -82
- package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +0 -103
- package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +0 -299
- package/node_modules/pi-lens/dist/clients/review-graph/types.js +0 -1
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +0 -449
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +0 -81
- package/node_modules/pi-lens/dist/clients/ruff-client.js +0 -179
- package/node_modules/pi-lens/dist/clients/rules-scanner.js +0 -131
- package/node_modules/pi-lens/dist/clients/run-duration.js +0 -55
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +0 -746
- package/node_modules/pi-lens/dist/clients/runtime-config.js +0 -77
- package/node_modules/pi-lens/dist/clients/runtime-context.js +0 -211
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +0 -1324
- package/node_modules/pi-lens/dist/clients/runtime-session.js +0 -2485
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +0 -1295
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +0 -2049
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +0 -3767
- package/node_modules/pi-lens/dist/clients/rust-client.js +0 -97
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +0 -2357
- package/node_modules/pi-lens/dist/clients/sanitize.js +0 -286
- package/node_modules/pi-lens/dist/clients/scan-utils.js +0 -25
- package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +0 -165
- package/node_modules/pi-lens/dist/clients/search-read-registration.js +0 -94
- package/node_modules/pi-lens/dist/clients/secret-findings.js +0 -114
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +0 -352
- package/node_modules/pi-lens/dist/clients/session-event-guard.js +0 -297
- package/node_modules/pi-lens/dist/clients/session-lifecycle.js +0 -484
- package/node_modules/pi-lens/dist/clients/session-start-observability.js +0 -94
- package/node_modules/pi-lens/dist/clients/session-state-store.js +0 -101
- package/node_modules/pi-lens/dist/clients/session-summary.js +0 -15
- package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +0 -27
- package/node_modules/pi-lens/dist/clients/sg-runner.js +0 -876
- package/node_modules/pi-lens/dist/clients/sgconfig.js +0 -347
- package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +0 -338
- package/node_modules/pi-lens/dist/clients/single-flight.js +0 -158
- package/node_modules/pi-lens/dist/clients/situational-tool-telemetry.js +0 -108
- package/node_modules/pi-lens/dist/clients/skills-resolver.js +0 -141
- package/node_modules/pi-lens/dist/clients/slow-fs.js +0 -138
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +0 -231
- package/node_modules/pi-lens/dist/clients/source-filter.js +0 -596
- package/node_modules/pi-lens/dist/clients/source-walker.js +0 -211
- package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +0 -120
- package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +0 -110
- package/node_modules/pi-lens/dist/clients/stale-marker.js +0 -17
- package/node_modules/pi-lens/dist/clients/startup-scan.js +0 -400
- package/node_modules/pi-lens/dist/clients/startup-timing.js +0 -130
- package/node_modules/pi-lens/dist/clients/string-utils.js +0 -33
- package/node_modules/pi-lens/dist/clients/subagent-mode.js +0 -111
- package/node_modules/pi-lens/dist/clients/symbol-containment.js +0 -24
- package/node_modules/pi-lens/dist/clients/symbol-types.js +0 -5
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +0 -2507
- package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +0 -287
- package/node_modules/pi-lens/dist/clients/todo-scanner.js +0 -229
- package/node_modules/pi-lens/dist/clients/tool-agreement.js +0 -423
- package/node_modules/pi-lens/dist/clients/tool-config.js +0 -249
- package/node_modules/pi-lens/dist/clients/tool-cwd.js +0 -270
- package/node_modules/pi-lens/dist/clients/tool-definition.js +0 -47
- package/node_modules/pi-lens/dist/clients/tool-event.js +0 -105
- package/node_modules/pi-lens/dist/clients/tool-policy.js +0 -2755
- package/node_modules/pi-lens/dist/clients/tool-probe.js +0 -53
- package/node_modules/pi-lens/dist/clients/tool-render.js +0 -156
- package/node_modules/pi-lens/dist/clients/tool-set-policy.js +0 -118
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +0 -364
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +0 -4195
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +0 -100
- package/node_modules/pi-lens/dist/clients/tree-sitter-navigator.js +0 -269
- package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +0 -569
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +0 -158
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
- package/node_modules/pi-lens/dist/clients/trivy-client.js +0 -467
- package/node_modules/pi-lens/dist/clients/tui-fit.js +0 -50
- package/node_modules/pi-lens/dist/clients/turn-context.js +0 -52
- package/node_modules/pi-lens/dist/clients/turn-end/lane.js +0 -51
- package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +0 -112
- package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +0 -204
- package/node_modules/pi-lens/dist/clients/turn-summary-render.js +0 -72
- package/node_modules/pi-lens/dist/clients/turn-summary.js +0 -132
- package/node_modules/pi-lens/dist/clients/typos-config.js +0 -29
- package/node_modules/pi-lens/dist/clients/user-notify.js +0 -51
- package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +0 -57
- package/node_modules/pi-lens/dist/clients/warm-attach.js +0 -305
- package/node_modules/pi-lens/dist/clients/widget-state.js +0 -1808
- package/node_modules/pi-lens/dist/clients/word-index-logger.js +0 -49
- package/node_modules/pi-lens/dist/clients/word-index-store.js +0 -459
- package/node_modules/pi-lens/dist/clients/word-index.js +0 -2080
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +0 -443
- package/node_modules/pi-lens/dist/clients/write-ordering-guard.js +0 -65
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +0 -340
- package/node_modules/pi-lens/dist/i18n.js +0 -69
- package/node_modules/pi-lens/dist/mcp/build-staleness.js +0 -124
- package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +0 -583
- package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +0 -129
- package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +0 -123
- package/node_modules/pi-lens/dist/tools/activate-tools.js +0 -87
- package/node_modules/pi-lens/dist/tools/ast-grep-outline.js +0 -137
- package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +0 -244
- package/node_modules/pi-lens/dist/tools/ast-grep-search.js +0 -762
- package/node_modules/pi-lens/dist/tools/effective-config.js +0 -90
- package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +0 -375
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +0 -2571
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +0 -1745
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +0 -1367
- package/node_modules/pi-lens/dist/tools/lsp-structured-output.js +0 -294
- package/node_modules/pi-lens/dist/tools/module-report.js +0 -382
- package/node_modules/pi-lens/dist/tools/project-report.js +0 -86
- package/node_modules/pi-lens/dist/tools/render-compact.js +0 -374
- package/node_modules/pi-lens/dist/tools/scan-progress.js +0 -53
- package/node_modules/pi-lens/dist/tools/shared.js +0 -29
- package/node_modules/pi-lens/dist/tools/symbol-search.js +0 -155
|
@@ -1,2357 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Safe cross-platform spawn utilities
|
|
3
|
-
*
|
|
4
|
-
* Provides both sync (deprecated) and async versions for gradual migration.
|
|
5
|
-
*
|
|
6
|
-
* Async version features:
|
|
7
|
-
* - Non-blocking execution
|
|
8
|
-
* - Proper process cleanup on timeout (no zombies)
|
|
9
|
-
* - Batch execution with concurrency limits
|
|
10
|
-
* - AbortSignal support for cancellation
|
|
11
|
-
*
|
|
12
|
-
* Migration guide:
|
|
13
|
-
* - Change: safeSpawn(cmd, args, opts)
|
|
14
|
-
* - To: await safeSpawnAsync(cmd, args, opts)
|
|
15
|
-
*/
|
|
16
|
-
import { spawn, spawnSync, } from "node:child_process";
|
|
17
|
-
import * as fs from "node:fs";
|
|
18
|
-
import * as path from "node:path";
|
|
19
|
-
import { BoundedFifoMap, BoundedSet } from "./bounded-cache.js";
|
|
20
|
-
import { logLatency } from "./latency-logger.js";
|
|
21
|
-
import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "./degradation-ledger.js";
|
|
22
|
-
import { logExtension } from "./extension-log.js";
|
|
23
|
-
import { isFullyQualifiedWin32 } from "./path-utils.js";
|
|
24
|
-
import { startSpawnUsageSampler } from "./resource-sampler.js";
|
|
25
|
-
import { DEFAULT_MAX_OUTPUT_BYTES } from "./spawn-output-cap.js";
|
|
26
|
-
import { compareOrdinal } from "./string-utils.js";
|
|
27
|
-
/** Intent-level spawn failure. `cause` retains the original OS Error/errno. */
|
|
28
|
-
export class SpawnFailureError extends Error {
|
|
29
|
-
kind;
|
|
30
|
-
cause;
|
|
31
|
-
name = "SpawnFailureError";
|
|
32
|
-
constructor(kind, message, cause) {
|
|
33
|
-
super(message, { cause });
|
|
34
|
-
this.kind = kind;
|
|
35
|
-
this.cause = cause;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
export function hasSpawnFailureKind(error, kind) {
|
|
39
|
-
return (error instanceof Error &&
|
|
40
|
-
"kind" in error &&
|
|
41
|
-
error.kind === kind);
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* #2010: derive teardown evidence from observed timestamps.
|
|
45
|
-
*
|
|
46
|
-
* Pure by design so every branch is deterministically unit-testable,
|
|
47
|
-
* including the natural-exit-at-budget-expiry race where the timeout
|
|
48
|
-
* callback arms a kill against an already-dead child.
|
|
49
|
-
*
|
|
50
|
-
* - No recorded kill start (the guard saw the child already dying), no
|
|
51
|
-
* observed death at all, or death PREDATING the kill start: the child
|
|
52
|
-
* left on its own -> "exited", ms 0. Claiming a kill we cannot
|
|
53
|
-
* distinguish from a natural exit would be invented evidence.
|
|
54
|
-
* - Death observed after the kill started: "escalate-kill" when the
|
|
55
|
-
* SIGKILL escalation fired, otherwise "killed-by-signal"; ms is the
|
|
56
|
-
* observed gap (floor 0 - clock jitter must never produce a negative).
|
|
57
|
-
*/
|
|
58
|
-
export function resolveTeardownOutcome(input) {
|
|
59
|
-
// Another path (abort / output-limit) owns this death: emitting teardown
|
|
60
|
-
// evidence here would describe a kill this timeout didn't perform.
|
|
61
|
-
if (input.killStartedAtMs === undefined ||
|
|
62
|
-
input.deathObservedAtMs === undefined) {
|
|
63
|
-
return undefined;
|
|
64
|
-
}
|
|
65
|
-
// True natural-exit race: the child beat the budget-expired kill by a
|
|
66
|
-
// hair - death observed before (or exactly at) the kill start.
|
|
67
|
-
if (input.deathObservedAtMs <= input.killStartedAtMs) {
|
|
68
|
-
return { ms: 0, outcome: "exited" };
|
|
69
|
-
}
|
|
70
|
-
return {
|
|
71
|
-
ms: Math.max(0, input.deathObservedAtMs - input.killStartedAtMs),
|
|
72
|
-
outcome: input.escalated ? "escalate-kill" : "killed-by-signal",
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
function toError(error) {
|
|
76
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
77
|
-
}
|
|
78
|
-
function errorCode(error) {
|
|
79
|
-
return error.code;
|
|
80
|
-
}
|
|
81
|
-
async function cwdIsUnresolvable(cwd) {
|
|
82
|
-
if (cwd === undefined)
|
|
83
|
-
return false;
|
|
84
|
-
try {
|
|
85
|
-
return !(await fs.promises.stat(cwd)).isDirectory();
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
function cwdIsUnresolvableSync(cwd) {
|
|
92
|
-
if (cwd === undefined)
|
|
93
|
-
return false;
|
|
94
|
-
try {
|
|
95
|
-
return !fs.statSync(cwd).isDirectory();
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Best-effort presence probe used ONLY to disambiguate ENOENT when the cwd is
|
|
103
|
-
* ALSO unresolvable (#1340 review): a genuinely missing tool must classify as
|
|
104
|
-
* tool-not-found even under a broken cwd, or auto-install can never repair it.
|
|
105
|
-
* Absolute commands are probed directly (with PATHEXT variants on Windows);
|
|
106
|
-
* bare names scan PATH. A relative-with-separator command under a broken cwd
|
|
107
|
-
* is genuinely ambiguous -- we err toward cwd-unresolvable there, because
|
|
108
|
-
* repairing the cwd is actionable while a reinstall loop (#1199) is not.
|
|
109
|
-
*/
|
|
110
|
-
function commandProbablyPresent(command) {
|
|
111
|
-
const exts = process.platform === "win32"
|
|
112
|
-
? [
|
|
113
|
-
"",
|
|
114
|
-
...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
115
|
-
.split(";")
|
|
116
|
-
.filter(Boolean),
|
|
117
|
-
]
|
|
118
|
-
: [""];
|
|
119
|
-
const existsWithExt = (base) => {
|
|
120
|
-
for (const ext of exts) {
|
|
121
|
-
try {
|
|
122
|
-
if (fs.existsSync(base + ext))
|
|
123
|
-
return true;
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
// unreadable candidate -- keep probing
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return false;
|
|
130
|
-
};
|
|
131
|
-
if (path.isAbsolute(command))
|
|
132
|
-
return existsWithExt(command);
|
|
133
|
-
if (command.includes("/") || command.includes("\\"))
|
|
134
|
-
return "ambiguous";
|
|
135
|
-
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
136
|
-
if (dir && existsWithExt(path.join(dir, command)))
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Shared errno->bucket mapping. `cwdUnresolvable` is the only async-vs-sync
|
|
143
|
-
* difference between the two public classifiers, so it arrives as a resolved
|
|
144
|
-
* flag and everything else lives once (Sonar duplication finding, #1340).
|
|
145
|
-
*/
|
|
146
|
-
function classifyWithCwdFlag(cause, options, cwdUnresolvable) {
|
|
147
|
-
const code = errorCode(cause);
|
|
148
|
-
let failure;
|
|
149
|
-
if (code === "ENOENT" &&
|
|
150
|
-
cwdUnresolvable &&
|
|
151
|
-
commandProbablyPresent(options.command) !== false) {
|
|
152
|
-
failure = new SpawnFailureError("cwd-unresolvable", `Cannot spawn ${options.command}: working directory is unresolvable (${options.cwd})`, cause);
|
|
153
|
-
}
|
|
154
|
-
else if (code === "ENOENT") {
|
|
155
|
-
failure = new SpawnFailureError("tool-not-found", `Cannot spawn ${options.command}: tool not found (${cause.message})`, cause);
|
|
156
|
-
}
|
|
157
|
-
else if (code === "EACCES" || code === "EPERM") {
|
|
158
|
-
failure = new SpawnFailureError("permission-denied", `Cannot spawn ${options.command}: permission denied`, cause);
|
|
159
|
-
}
|
|
160
|
-
else {
|
|
161
|
-
failure = new SpawnFailureError("spawn-failed", `Cannot spawn ${options.command}: ${cause.message}`, cause);
|
|
162
|
-
}
|
|
163
|
-
recordSpawnClassification(failure, options);
|
|
164
|
-
return failure;
|
|
165
|
-
}
|
|
166
|
-
const loggedSpawnClassifications = new Set();
|
|
167
|
-
const SPAWN_CLASSIFICATION_LOG_CAP = 200;
|
|
168
|
-
function recordSpawnClassification(failure, options) {
|
|
169
|
-
const pair = `${failure.kind}\0${options.command}`;
|
|
170
|
-
if (!loggedSpawnClassifications.has(pair)) {
|
|
171
|
-
if (loggedSpawnClassifications.size >= SPAWN_CLASSIFICATION_LOG_CAP) {
|
|
172
|
-
loggedSpawnClassifications.clear();
|
|
173
|
-
}
|
|
174
|
-
loggedSpawnClassifications.add(pair);
|
|
175
|
-
logExtension({
|
|
176
|
-
subsystem: "safe-spawn",
|
|
177
|
-
level: "debug",
|
|
178
|
-
message: "spawn failure classified",
|
|
179
|
-
metadata: {
|
|
180
|
-
kind: failure.kind,
|
|
181
|
-
command: options.command,
|
|
182
|
-
cwd: options.cwd,
|
|
183
|
-
},
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
if (failure.kind !== "tool-not-found") {
|
|
187
|
-
recordDegradation({
|
|
188
|
-
kind: "spawn-failure",
|
|
189
|
-
subject: options.command,
|
|
190
|
-
reason: `${failure.kind}${options.cwd ? ` in ${options.cwd}` : ""}`,
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
/** Classify a raw Node spawn error without discarding its errno-bearing Error. */
|
|
195
|
-
export async function classifySpawnFailure(error, options) {
|
|
196
|
-
const cause = toError(error);
|
|
197
|
-
const needsCwdProbe = errorCode(cause) === "ENOENT";
|
|
198
|
-
const cwdUnresolvable = needsCwdProbe && (await cwdIsUnresolvable(options.cwd));
|
|
199
|
-
return classifyWithCwdFlag(cause, options, cwdUnresolvable);
|
|
200
|
-
}
|
|
201
|
-
function classifySpawnFailureSync(error, options) {
|
|
202
|
-
const cause = toError(error);
|
|
203
|
-
const needsCwdProbe = errorCode(cause) === "ENOENT";
|
|
204
|
-
const cwdUnresolvable = needsCwdProbe && cwdIsUnresolvableSync(options.cwd);
|
|
205
|
-
return classifyWithCwdFlag(cause, options, cwdUnresolvable);
|
|
206
|
-
}
|
|
207
|
-
// Vitest reloads modules inside a reused worker. Keep this registry on the
|
|
208
|
-
// process so those module instances share one signal/exit listener set.
|
|
209
|
-
const lifetimeStateKey = Symbol.for("pi-lens.safe-spawn.lifetime-state");
|
|
210
|
-
const processWithLifetimeState = process;
|
|
211
|
-
const lifetimeState = processWithLifetimeState[lifetimeStateKey] ??
|
|
212
|
-
(processWithLifetimeState[lifetimeStateKey] = {
|
|
213
|
-
pids: new Set(),
|
|
214
|
-
installed: false,
|
|
215
|
-
});
|
|
216
|
-
/**
|
|
217
|
-
* THE ownership predicate for every kill-by-raw-pid in this repo (#2042).
|
|
218
|
-
*
|
|
219
|
-
* A pid is signalable only when it is a LIVE DIRECT CHILD of this process.
|
|
220
|
-
* `tests/clients/lsp/launch.test.ts` mocked `node:child_process` with a fake
|
|
221
|
-
* child carrying the literal pid `2468`; `safeSpawnAsync` registered it in
|
|
222
|
-
* `lifetimeState.pids`, the fake never emitted `close` so nothing removed it,
|
|
223
|
-
* and at fork teardown `installLifetimeCleanup()` fired
|
|
224
|
-
* `process.kill(-2468, "SIGKILL")`. On ~10 % of GitHub runners pid 2468 was
|
|
225
|
-
* one of the job's own long-lived processes — that is the whole of #2042:
|
|
226
|
-
* exit 137, no failing assertion, no kernel record, five weeks of "infra
|
|
227
|
-
* kill" reruns. A `pid <= 0` guard would not have stopped it; 2468 is a
|
|
228
|
-
* perfectly plausible pid. The defect is OWNERSHIP, not sign.
|
|
229
|
-
*
|
|
230
|
-
* Three guards fold into this one (net-count rule): `lsp/client.ts`'s
|
|
231
|
-
* `pid <= 0`, `lsp/launch.ts`'s `handle.pid <= 0` plus its already-exited
|
|
232
|
-
* recycled-pid check, and `killTree`'s `child.pid > 0` below. It closes
|
|
233
|
-
* fabricated, stale and recycled pids together.
|
|
234
|
-
*
|
|
235
|
-
* NOT folded: `instance-reaper.ts#killPidTree`. Its pids come from OTHER
|
|
236
|
-
* pi-lens instances' registries and from an OS scan of managed binaries with
|
|
237
|
-
* confirmed-dead parents — deliberately NOT our children. Its `!isFinite ||
|
|
238
|
-
* pid <= 0` guard is well-formedness on a different axis, and applying this
|
|
239
|
-
* predicate there would turn the orphan reaper into a no-op.
|
|
240
|
-
*
|
|
241
|
-
* Platform behaviour, stated rather than implied:
|
|
242
|
-
* - Linux (the authoritative lane, and the one that lost five weeks):
|
|
243
|
-
* `/proc/<pid>/status` is the kernel's own answer. Missing entry = the pid
|
|
244
|
-
* does not exist, so there is nothing of ours to kill; `PPid` other than
|
|
245
|
-
* ours = someone else's process, which is recorded and never signalled.
|
|
246
|
-
* - Windows and macOS have no `/proc` (and neither does a Linux host with
|
|
247
|
-
* `/proc` unmounted — probed once, not assumed from `process.platform`),
|
|
248
|
-
* so ownership is unverifiable from a raw pid and the predicate keeps
|
|
249
|
-
* today's best-effort behaviour. The `handle` arm applies THERE and only
|
|
250
|
-
* there: it is the only ownership evidence Windows has, and it is what
|
|
251
|
-
* `killWindowsTree` already used. It must not gate the POSIX group kill,
|
|
252
|
-
* which legitimately runs after its leader has died (#3091 F1).
|
|
253
|
-
*/
|
|
254
|
-
/**
|
|
255
|
-
* Whether `/proc/<pid>/status` can actually be read here, probed ONCE against
|
|
256
|
-
* this process's own entry (#3091 F4). `process.platform === "linux"` is not
|
|
257
|
-
* the same question: a container or a hardened host can run Linux with no
|
|
258
|
-
* `/proc` mounted, and there the per-pid read fails exactly the way a dead pid
|
|
259
|
-
* does. Without this probe that silently refused every registration, so
|
|
260
|
-
* `lifetimeState.pids` stayed empty and the host-exit tree kill stopped working
|
|
261
|
-
* with no record anywhere.
|
|
262
|
-
*/
|
|
263
|
-
const PROC_PPID_READABLE = (() => {
|
|
264
|
-
if (process.platform !== "linux")
|
|
265
|
-
return false;
|
|
266
|
-
try {
|
|
267
|
-
return /^PPid:\s*\d+$/m.test(fs.readFileSync("/proc/self/status", "utf8"));
|
|
268
|
-
}
|
|
269
|
-
catch {
|
|
270
|
-
return false;
|
|
271
|
-
}
|
|
272
|
-
})();
|
|
273
|
-
/**
|
|
274
|
-
* Pids this process has PROVEN, from the kernel, to be its own live children.
|
|
275
|
-
* FIFO-bounded (`BoundedFifoMap`, not a wholesale clear): a process that spawns
|
|
276
|
-
* more than the cap drops its OLDEST verdicts, where a wholesale clear would
|
|
277
|
-
* drop the one it is about to need.
|
|
278
|
-
*/
|
|
279
|
-
export const VERIFIED_OWN_PID_CAP = 512;
|
|
280
|
-
const verifiedOwnPids = new BoundedFifoMap(VERIFIED_OWN_PID_CAP);
|
|
281
|
-
/**
|
|
282
|
-
* Pids whose OWNERSHIP IS HELD for the lifetime of a long-lived resource —
|
|
283
|
-
* today, the LSP children `clients/lsp/launch.ts` spawns with `nodeSpawn`
|
|
284
|
-
* (#3091 F1-r2b).
|
|
285
|
-
*
|
|
286
|
-
* These deliberately do NOT live in `verifiedOwnPids`. Age is the wrong
|
|
287
|
-
* retirement axis for a resource-scoped verdict: an LSP leader is verified once
|
|
288
|
-
* at spawn and then sits untouched for the whole session, so in a FIFO that
|
|
289
|
-
* every `safeSpawnAsync` writes to it is the OLDEST entry and therefore the
|
|
290
|
-
* FIRST evicted — measured, 520 later verdicts were enough — and its group kill
|
|
291
|
-
* at host exit is refused precisely because the server was long-lived enough to
|
|
292
|
-
* matter. Retirement here is by RESOURCE STATE instead: `releaseOwnChildPid`
|
|
293
|
-
* when the shutdown ladder is done with the pid, plus the confirmed-dead sweep
|
|
294
|
-
* below for the paths that never reach a ladder (a server that crashes, or a
|
|
295
|
-
* host killed mid-session).
|
|
296
|
-
*
|
|
297
|
-
* The bound that remains, stated rather than implied: at most
|
|
298
|
-
* `HELD_OWN_PID_SWEEP_AT` entries — the confirmed-dead sweep below runs before
|
|
299
|
-
* every insert, and `BoundedSet`'s own FIFO overflow is the last resort for the
|
|
300
|
-
* case where every held group really is still alive. A crash-looping server cannot grow it without bound
|
|
301
|
-
* because each sweep drops every pid whose process GROUP no longer exists,
|
|
302
|
-
* which is exactly the condition under which the entry can never be needed
|
|
303
|
-
* again.
|
|
304
|
-
*/
|
|
305
|
-
const HELD_OWN_PID_SWEEP_AT = 256;
|
|
306
|
-
const heldOwnPids = new BoundedSet(HELD_OWN_PID_SWEEP_AT);
|
|
307
|
-
/** A process group that no longer exists can never need another signal. */
|
|
308
|
-
function groupIsGone(pid) {
|
|
309
|
-
try {
|
|
310
|
-
process.kill(-pid, 0);
|
|
311
|
-
return false;
|
|
312
|
-
}
|
|
313
|
-
catch (error) {
|
|
314
|
-
return errorCode(error) === "ESRCH";
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
function sweepHeldOwnPids() {
|
|
318
|
-
if (heldOwnPids.size < HELD_OWN_PID_SWEEP_AT)
|
|
319
|
-
return;
|
|
320
|
-
// Deleting the current entry mid-iteration is well-defined for a Set, so
|
|
321
|
-
// no snapshot copy is needed.
|
|
322
|
-
for (const pid of heldOwnPids) {
|
|
323
|
-
if (groupIsGone(pid))
|
|
324
|
-
heldOwnPids.delete(pid);
|
|
325
|
-
}
|
|
326
|
-
// No hand-rolled "drop the oldest" block (#2442 / #3091 round 4): the
|
|
327
|
-
// last-resort eviction, for the case where every held group is still alive
|
|
328
|
-
// at the ceiling, is `BoundedSet.add`'s own FIFO overflow.
|
|
329
|
-
}
|
|
330
|
-
/**
|
|
331
|
-
* Verify a pid at ADMISSION and hold the verdict for the resource's lifetime.
|
|
332
|
-
*
|
|
333
|
-
* This is AGENTS.md shape 50's own rule, which #3091 round 1 broke at the site
|
|
334
|
-
* it was fixing: `isOwnLiveChild`'s three call sites were the `safeSpawnAsync`
|
|
335
|
-
* registration, the Windows tree kill, and the SHUTDOWN group kill — so for an
|
|
336
|
-
* LSP child, spawned with `nodeSpawn`, the first offer of verification was also
|
|
337
|
-
* the last, and on the `processExiting` path the leader could already be dead
|
|
338
|
-
* by then (`/proc` gone, nothing on record, signal refused, grandchildren
|
|
339
|
-
* orphaned).
|
|
340
|
-
*/
|
|
341
|
-
export function holdOwnChildPid(pid, site) {
|
|
342
|
-
const owned = isOwnLiveChild(pid, site);
|
|
343
|
-
if (!owned || !PROC_PPID_READABLE || typeof pid !== "number")
|
|
344
|
-
return owned;
|
|
345
|
-
// Exactly one home: the verification above filed the verdict in the
|
|
346
|
-
// age-bounded FIFO, and leaving a copy there would make BOTH the held set
|
|
347
|
-
// and its release mutation-inert — the FIFO copy would answer for them.
|
|
348
|
-
verifiedOwnPids.delete(pid);
|
|
349
|
-
sweepHeldOwnPids();
|
|
350
|
-
heldOwnPids.add(pid);
|
|
351
|
-
return true;
|
|
352
|
-
}
|
|
353
|
-
/**
|
|
354
|
-
* Retire a held pid once nothing will signal it again — the end of the
|
|
355
|
-
* shutdown ladder. Idempotent.
|
|
356
|
-
*/
|
|
357
|
-
export function releaseOwnChildPid(pid) {
|
|
358
|
-
if (typeof pid === "number")
|
|
359
|
-
heldOwnPids.delete(pid);
|
|
360
|
-
}
|
|
361
|
-
/** Once per session: a Linux host whose `/proc` cannot answer the question. */
|
|
362
|
-
function recordProcUnavailable(site) {
|
|
363
|
-
if (process.platform !== "linux")
|
|
364
|
-
return;
|
|
365
|
-
recordDegradationOnce({
|
|
366
|
-
kind: "kill-ownership-unverifiable",
|
|
367
|
-
subject: site,
|
|
368
|
-
reason: "/proc/self/status is unreadable on this Linux host; kill-by-pid ownership falls back to best-effort",
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
export function isOwnLiveChild(pid, site, handle) {
|
|
372
|
-
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
|
|
373
|
-
return false;
|
|
374
|
-
if (!PROC_PPID_READABLE) {
|
|
375
|
-
// No `/proc` to consult. A handle that has already reported exit is then
|
|
376
|
-
// the ONLY ownership evidence available, and it is exactly what
|
|
377
|
-
// `killWindowsTree` used before this predicate existed: a dead pid may
|
|
378
|
-
// have been recycled, and `taskkill /F /T` on a recycled pid destroys an
|
|
379
|
-
// unrelated tree. It overrides the memo below deliberately.
|
|
380
|
-
if (handle && (handle.exitCode != null || handle.signalCode != null))
|
|
381
|
-
return false;
|
|
382
|
-
recordProcUnavailable(site);
|
|
383
|
-
return true;
|
|
384
|
-
}
|
|
385
|
-
let status;
|
|
386
|
-
try {
|
|
387
|
-
status = fs.readFileSync(`/proc/${pid}/status`, "utf8");
|
|
388
|
-
}
|
|
389
|
-
catch {
|
|
390
|
-
// The pid does not exist. Not a degradation — a child that exited before
|
|
391
|
-
// the teardown signal is the ordinary case — but not automatically a
|
|
392
|
-
// refusal either: a POSIX process GROUP outlives its leader, and #2026's
|
|
393
|
-
// whole point is that the escalation must still reach a SIGTERM-hardy
|
|
394
|
-
// grandchild after the direct child has died. Ownership verified while
|
|
395
|
-
// the leader was alive is what answers that; a pid this process never
|
|
396
|
-
// owned is still refused.
|
|
397
|
-
return heldOwnPids.has(pid) || verifiedOwnPids.has(pid);
|
|
398
|
-
}
|
|
399
|
-
const parent = /^PPid:\s*(\d+)$/m.exec(status);
|
|
400
|
-
if (!parent)
|
|
401
|
-
return true;
|
|
402
|
-
if (Number(parent[1]) === process.pid) {
|
|
403
|
-
// Memoized only on a KERNEL-verified verdict, and only here: a pid we
|
|
404
|
-
// never proved was ours can never enter the memo, and a pid that is
|
|
405
|
-
// alive under a different parent is refused above regardless of what the
|
|
406
|
-
// memo holds — so a recycled pid cannot be signalled on the strength of
|
|
407
|
-
// its previous owner.
|
|
408
|
-
//
|
|
409
|
-
// Never for a pid already HELD, though: the shutdown ladder verifies a
|
|
410
|
-
// still-live leader on its way to the group signal, and filing that
|
|
411
|
-
// verdict in the FIFO too would put a held pid back in both stores —
|
|
412
|
-
// which outlives `releaseOwnChildPid` (the FIFO copy would keep
|
|
413
|
-
// answering for a pid this process has explicitly stopped claiming) and
|
|
414
|
-
// makes the held store's own tests unable to fail.
|
|
415
|
-
if (!heldOwnPids.has(pid))
|
|
416
|
-
verifiedOwnPids.set(pid, true);
|
|
417
|
-
return true;
|
|
418
|
-
}
|
|
419
|
-
// Alive AND someone else's: the dangerous case, and the only one worth a
|
|
420
|
-
// record. Subject is the SITE (a fixed, tiny set), never the pid, so the
|
|
421
|
-
// ledger stays bounded however often it fires.
|
|
422
|
-
incrementDegradationCount({
|
|
423
|
-
kind: "kill-foreign-pid-refused",
|
|
424
|
-
subject: site,
|
|
425
|
-
reason: `pid ${pid} has parent ${parent[1]}, not this process — signal refused`,
|
|
426
|
-
});
|
|
427
|
-
return false;
|
|
428
|
-
}
|
|
429
|
-
/**
|
|
430
|
-
* Kill one registered pid's tree at host exit. Deliberately NOT re-checked
|
|
431
|
-
* against `isOwnLiveChild` here: `lifetimeState.pids` has exactly one writer
|
|
432
|
-
* (the registration below), that writer is now gated, and a second gate on
|
|
433
|
-
* the same feeder would make BOTH mutation-inert — remove either one and the
|
|
434
|
-
* other still blocks the kill, so neither would have a test that reds. The
|
|
435
|
-
* gate lives where the pid enters, which is also where ownership is a fact
|
|
436
|
-
* rather than a stale reading (#2042).
|
|
437
|
-
*/
|
|
438
|
-
function killPidTreeSync(pid) {
|
|
439
|
-
if (process.platform === "win32") {
|
|
440
|
-
try {
|
|
441
|
-
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
|
|
442
|
-
spawnSync(taskkill, ["/F", "/T", "/PID", String(pid)], {
|
|
443
|
-
shell: false,
|
|
444
|
-
windowsHide: true,
|
|
445
|
-
stdio: "ignore",
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
catch {
|
|
449
|
-
// Runs from process `exit`/signal handlers — a SYNCHRONOUS spawn throw
|
|
450
|
-
// (Windows `spawn UNKNOWN`/EINVAL, the pidusage bug class, #533) would
|
|
451
|
-
// become an uncaughtException during shutdown. Best-effort tree-kill:
|
|
452
|
-
// swallow it, mirroring the already-guarded POSIX branch below.
|
|
453
|
-
}
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
try {
|
|
457
|
-
// #2026: negative pid = whole process group (POSIX children spawn
|
|
458
|
-
// detached), reaching grandchildren too. Any failure (ESRCH = already
|
|
459
|
-
// gone, EPERM = raced) falls through to the direct-child attempt.
|
|
460
|
-
try {
|
|
461
|
-
process.kill(-pid, "SIGKILL");
|
|
462
|
-
}
|
|
463
|
-
catch {
|
|
464
|
-
process.kill(pid, "SIGKILL");
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
catch {
|
|
468
|
-
// Child already exited.
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
function installLifetimeCleanup() {
|
|
472
|
-
if (lifetimeState.installed)
|
|
473
|
-
return;
|
|
474
|
-
lifetimeState.installed = true;
|
|
475
|
-
process.once("exit", () => {
|
|
476
|
-
for (const pid of lifetimeState.pids)
|
|
477
|
-
killPidTreeSync(pid);
|
|
478
|
-
});
|
|
479
|
-
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
480
|
-
process.once(signal, () => {
|
|
481
|
-
for (const pid of lifetimeState.pids)
|
|
482
|
-
killPidTreeSync(pid);
|
|
483
|
-
if (process.platform === "win32" && signal === "SIGHUP") {
|
|
484
|
-
// Windows emits SIGHUP when its console closes, but libuv cannot
|
|
485
|
-
// self-send it (process.kill(..., "SIGHUP") throws ENOSYS). The
|
|
486
|
-
// child cleanup above remains the required shutdown action; record
|
|
487
|
-
// the intentional decline synchronously and let the host exit cleanly.
|
|
488
|
-
recordDegradationOnce({
|
|
489
|
-
kind: "safe-spawn-signal-reraise-unsupported",
|
|
490
|
-
subject: `${process.platform}:${signal}`,
|
|
491
|
-
reason: `${process.platform} ${signal}: self signal re-raise is unsupported by the Windows libuv signal capability`,
|
|
492
|
-
metadata: {
|
|
493
|
-
platform: process.platform,
|
|
494
|
-
signal,
|
|
495
|
-
reason: "unsupported",
|
|
496
|
-
},
|
|
497
|
-
});
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
try {
|
|
501
|
-
process.kill(process.pid, signal);
|
|
502
|
-
}
|
|
503
|
-
catch (error) {
|
|
504
|
-
// #3383: this runs inside a `process.once(signal)` handler, so a
|
|
505
|
-
// throw here is an uncaughtException DURING SHUTDOWN, not a
|
|
506
|
-
// rejection anyone can see. The one failure measured in the field
|
|
507
|
-
// (Windows SIGHUP ENOSYS) is declined in advance just above; this
|
|
508
|
-
// covers every other platform/signal/errno pair the OS can refuse —
|
|
509
|
-
// same ledger kind, `reason: "refused"` instead of "unsupported",
|
|
510
|
-
// because a reader of either row wants the same fact: the host
|
|
511
|
-
// exited without the signal's default disposition. The child
|
|
512
|
-
// cleanup above has already run either way.
|
|
513
|
-
recordDegradationOnce({
|
|
514
|
-
kind: "safe-spawn-signal-reraise-unsupported",
|
|
515
|
-
subject: `${process.platform}:${signal}`,
|
|
516
|
-
reason: `${process.platform} ${signal}: self signal re-raise was refused (${error.code ?? error.message})`,
|
|
517
|
-
metadata: {
|
|
518
|
-
platform: process.platform,
|
|
519
|
-
signal,
|
|
520
|
-
reason: "refused",
|
|
521
|
-
code: error.code ?? null,
|
|
522
|
-
},
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
// ============================================================================
|
|
529
|
-
// AMBIENT TURN ABORT SIGNAL
|
|
530
|
-
// ============================================================================
|
|
531
|
-
/**
|
|
532
|
-
* The current turn's abort signal, published by the lifecycle handlers from
|
|
533
|
-
* pi's `ctx.signal`. Threading the signal explicitly through every
|
|
534
|
-
* dispatch → runner → spawn call site would be invasive, so instead
|
|
535
|
-
* `safeSpawnAsync` defaults to this ambient signal when a call doesn't pass its
|
|
536
|
-
* own. The effect: pressing Esc mid-turn aborts in-flight linter / formatter /
|
|
537
|
-
* type-checker child processes (process-tree kill on Windows) instead of letting
|
|
538
|
-
* them run to their timeout.
|
|
539
|
-
*
|
|
540
|
-
* Each spawn captures the signal at call time (attaching its own abort listener),
|
|
541
|
-
* so clearing this after a handler returns only affects *future* spawns — work
|
|
542
|
-
* already in flight keeps the signal it started with.
|
|
543
|
-
*/
|
|
544
|
-
let ambientAbortSignal;
|
|
545
|
-
/** Publish (or clear, with `undefined`) the current turn's abort signal. */
|
|
546
|
-
export function setAmbientAbortSignal(signal) {
|
|
547
|
-
ambientAbortSignal = signal;
|
|
548
|
-
}
|
|
549
|
-
/**
|
|
550
|
-
* The current turn's abort signal, for in-process awaits that aren't child
|
|
551
|
-
* spawns (e.g. an LSP JSON-RPC write that can backpressure on a wedged server).
|
|
552
|
-
* Child spawns read the ambient signal internally; this getter lets the
|
|
553
|
-
* interactive pipeline honor Escape on non-spawn LSP calls too.
|
|
554
|
-
*/
|
|
555
|
-
export function getAmbientAbortSignal() {
|
|
556
|
-
return ambientAbortSignal;
|
|
557
|
-
}
|
|
558
|
-
// ============================================================================
|
|
559
|
-
// INTERNAL HELPERS
|
|
560
|
-
// ============================================================================
|
|
561
|
-
/**
|
|
562
|
-
* Escape a single argument for the Windows command interpreter.
|
|
563
|
-
* The result is embedded in the `/c` command string below, so metacharacters
|
|
564
|
-
* must remain inside a quoted argument rather than becoming command syntax.
|
|
565
|
-
*/
|
|
566
|
-
function cmdEscapeArg(arg) {
|
|
567
|
-
if (!/[\s"&|<>^()]/.test(arg))
|
|
568
|
-
return arg;
|
|
569
|
-
return `"${arg.replace(/"/g, '""')}"`;
|
|
570
|
-
}
|
|
571
|
-
/**
|
|
572
|
-
* Build the cmd.exe command string used for Windows wrapper spawning.
|
|
573
|
-
*
|
|
574
|
-
* The COMMAND must be escaped the same way as the args — escaping only the args
|
|
575
|
-
* (the bug behind #214) means a tool whose resolved path contains a space (e.g.
|
|
576
|
-
* `C:\Program Files\Go\bin\go.exe`) makes cmd.exe parse `C:\Program` as the
|
|
577
|
-
* command and fail with "'C:\Program' is not recognized". `cmdEscapeArg` is a
|
|
578
|
-
* no-op for space-free commands, so this is safe for the npm/.pi-lens tool paths
|
|
579
|
-
* that already worked. The `chcp 65001` prefix forces the UTF-8 code page (so
|
|
580
|
-
* tool output isn't mangled by the system code page) and, as a side benefit,
|
|
581
|
-
* keeps the (possibly quoted) command off the front of the line, avoiding
|
|
582
|
-
* cmd.exe's `/s` outer-quote-stripping quirk. #2023: chcp is invoked via its
|
|
583
|
-
* absolute `%SystemRoot%\System32\chcp.com` path and chained with `&`, not
|
|
584
|
-
* `&&` — a child environment whose PATH cannot resolve System32 used to fail
|
|
585
|
-
* the bare `chcp` lookup, and `&&` then short-circuited EVERY .cmd/.bat spawn
|
|
586
|
-
* into exit 1 with empty output. With the pin plus `&`, even a chcp failure
|
|
587
|
-
* can never suppress the real command.
|
|
588
|
-
*/
|
|
589
|
-
export function buildWindowsShellCommand(command, args) {
|
|
590
|
-
const chcp = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\chcp.com`;
|
|
591
|
-
return `${chcp} 65001 >nul 2>&1 & ${[command, ...args].map(cmdEscapeArg).join(" ")}`;
|
|
592
|
-
}
|
|
593
|
-
/**
|
|
594
|
-
* The extensions this resolver treats as executable suffixes on their own,
|
|
595
|
-
* independent of PATHEXT: `.exe`/`.com` and `.cmd`/`.bat`. An explicit suffix
|
|
596
|
-
* outside this set is not treated as a "known executable extension" unless
|
|
597
|
-
* the caller's own PATHEXT says so (#1201).
|
|
598
|
-
*
|
|
599
|
-
* This is a RESOLUTION set, not a dispatch whitelist — do not read it as "the
|
|
600
|
-
* only extensions this module can spawn". Dispatch in
|
|
601
|
-
* `safeSpawnAsync`/`safeSpawn` below routes `.cmd`/`.bat` through the cmd.exe
|
|
602
|
-
* wrapper and EVERYTHING ELSE to direct spawn, so a resolved path with any
|
|
603
|
-
* other suffix (reachable only when the caller's own PATHEXT lists it) is
|
|
604
|
-
* direct-spawned. That is deliberate and not a widening: CreateProcess does no
|
|
605
|
-
* ShellExecute file-association lookup, the default Windows PATHEXT already
|
|
606
|
-
* contains `.VBS`/`.JS`/`.WSF`/`.MSC`, and the set here is strictly narrower
|
|
607
|
-
* than the pre-#1201 behavior. Only the doc claim was ever wrong.
|
|
608
|
-
*/
|
|
609
|
-
const WINDOWS_DIRECT_RESOLVABLE_EXTS = new Set([
|
|
610
|
-
".exe",
|
|
611
|
-
".com",
|
|
612
|
-
".cmd",
|
|
613
|
-
".bat",
|
|
614
|
-
]);
|
|
615
|
-
/**
|
|
616
|
-
* Merge a parent and child environment using Windows' case-insensitive variable
|
|
617
|
-
* names. Node's JavaScript environment object can contain both `PATH` and
|
|
618
|
-
* `Path`, even though Windows treats them as one variable. Removing an older
|
|
619
|
-
* spelling before each assignment gives explicit child overrides precedence
|
|
620
|
-
* over every ambient spelling and ensures the environment passed to spawn has
|
|
621
|
-
* one unambiguous value.
|
|
622
|
-
*/
|
|
623
|
-
export function mergeWindowsEnvironment(base, overrides) {
|
|
624
|
-
const entries = new Map();
|
|
625
|
-
const assign = (source) => {
|
|
626
|
-
if (!source)
|
|
627
|
-
return;
|
|
628
|
-
for (const [key, value] of Object.entries(source)) {
|
|
629
|
-
const folded = key.toLowerCase();
|
|
630
|
-
// Delete before setting so the last explicit spelling wins while the
|
|
631
|
-
// folded map remains O(n) instead of scanning the merged object for
|
|
632
|
-
// every environment entry.
|
|
633
|
-
entries.delete(folded);
|
|
634
|
-
if (value !== undefined)
|
|
635
|
-
entries.set(folded, { key, value });
|
|
636
|
-
}
|
|
637
|
-
};
|
|
638
|
-
assign(base);
|
|
639
|
-
assign(overrides);
|
|
640
|
-
const merged = {};
|
|
641
|
-
for (const { key, value } of entries.values())
|
|
642
|
-
merged[key] = value;
|
|
643
|
-
return merged;
|
|
644
|
-
}
|
|
645
|
-
function getWindowsEnvironmentValue(env, name) {
|
|
646
|
-
let value;
|
|
647
|
-
for (const [key, entry] of Object.entries(env)) {
|
|
648
|
-
if (key.toLowerCase() === name.toLowerCase())
|
|
649
|
-
value = entry;
|
|
650
|
-
}
|
|
651
|
-
return value;
|
|
652
|
-
}
|
|
653
|
-
function driveLetter(value) {
|
|
654
|
-
const match = /^([A-Za-z]):/.exec(value);
|
|
655
|
-
return match?.[1]?.toUpperCase();
|
|
656
|
-
}
|
|
657
|
-
function isDriveAbsolute(value, drive) {
|
|
658
|
-
return (value.length >= 3 &&
|
|
659
|
-
value[0]?.toUpperCase() === drive.toUpperCase() &&
|
|
660
|
-
value[1] === ":" &&
|
|
661
|
-
(value[2] === "\\" || value[2] === "/"));
|
|
662
|
-
}
|
|
663
|
-
/** `X:\...` or `X:/...` — a drive-absolute path, for any drive letter. */
|
|
664
|
-
function isDriveAbsoluteAnyDrive(value) {
|
|
665
|
-
const drive = driveLetter(value);
|
|
666
|
-
return drive !== undefined && isDriveAbsolute(value, drive);
|
|
667
|
-
}
|
|
668
|
-
/** `\\server\share\...` — a UNC path. Two leading separators, not more. */
|
|
669
|
-
function isUncWindowsPath(value) {
|
|
670
|
-
return ((value[0] === "\\" || value[0] === "/") &&
|
|
671
|
-
(value[1] === "\\" || value[1] === "/") &&
|
|
672
|
-
value[2] !== "\\" &&
|
|
673
|
-
value[2] !== "/");
|
|
674
|
-
}
|
|
675
|
-
/**
|
|
676
|
-
* A path is "fully qualified" (self-contained, no ambient current-directory
|
|
677
|
-
* lookup required) only when it is drive-absolute (`X:\...`) or UNC
|
|
678
|
-
* (`\\server\share`). `path.win32.isAbsolute("\tools")` also returns `true`
|
|
679
|
-
* for a *rooted* path — one relative to the current drive's root rather than
|
|
680
|
-
* a specific drive — and treating that as fully qualified was the #1201
|
|
681
|
-
* bug: it got statSync'd against the *host* drive while a different
|
|
682
|
-
* `effectiveCwd`/`resolvedDrive` supplied the drive cmd.exe actually used to
|
|
683
|
-
* execute it, so a different file could be validated than executed.
|
|
684
|
-
*/
|
|
685
|
-
function isFullyQualifiedWindowsPath(value) {
|
|
686
|
-
return isFullyQualifiedWin32(value) && !isRootedWindowsPath(value);
|
|
687
|
-
}
|
|
688
|
-
/**
|
|
689
|
-
* `\tools` or `/tools` — rooted at the current drive's root, but naming no
|
|
690
|
-
* drive of its own (distinct from both `X:\tools` fully-qualified and
|
|
691
|
-
* `X:tools` drive-relative-to-current-directory). Windows resolves this
|
|
692
|
-
* against the root of whichever drive is "current", not the current
|
|
693
|
-
* directory on that drive.
|
|
694
|
-
*/
|
|
695
|
-
function isRootedWindowsPath(value) {
|
|
696
|
-
return ((value[0] === "\\" || value[0] === "/") &&
|
|
697
|
-
driveLetter(value) === undefined &&
|
|
698
|
-
!isUncWindowsPath(value));
|
|
699
|
-
}
|
|
700
|
-
/**
|
|
701
|
-
* Resolve a rooted path (`\tools`) against the ROOT of `driveSource`'s drive
|
|
702
|
-
* — not `driveSource` itself — matching Windows' own rooted-path semantics.
|
|
703
|
-
*
|
|
704
|
-
* When `driveSource` carries NO drive letter, this returns the normalized
|
|
705
|
-
* value rather than `undefined` (#1201, recurring defect shape 2). A
|
|
706
|
-
* drive-less "current drive" provenance means we are not on a drive-lettered
|
|
707
|
-
* filesystem at all — a POSIX host, or a UNC `process.cwd()` such as
|
|
708
|
-
* `\\server\share\...`, which Node permits on real Windows. In that namespace
|
|
709
|
-
* a rooted path is already as qualified as any path can be, so there is
|
|
710
|
-
* nothing to anchor and normalizing is the correct (and pre-#1201) answer.
|
|
711
|
-
* Returning `undefined` instead used to collapse the whole resolution to
|
|
712
|
-
* `null` without a single `statSync`, i.e. a false ENOENT — the exact failure
|
|
713
|
-
* signature #1199 exists to remove. This function must therefore never fail
|
|
714
|
-
* closed on the *shape* of a value that may not be a Windows path at all;
|
|
715
|
-
* the filesystem probe downstream is what decides whether it exists.
|
|
716
|
-
*/
|
|
717
|
-
function resolveRootedWindowsPath(value, driveSource) {
|
|
718
|
-
const drive = driveLetter(driveSource);
|
|
719
|
-
if (drive === undefined)
|
|
720
|
-
return path.win32.normalize(value);
|
|
721
|
-
return path.win32.normalize(path.win32.resolve(`${drive}:\\`, value));
|
|
722
|
-
}
|
|
723
|
-
/**
|
|
724
|
-
* Return a validated Windows per-drive current directory. Windows exposes
|
|
725
|
-
* these as environment entries such as `=D:`; unlike ordinary environment
|
|
726
|
-
* variables they are not safe to synthesize from a drive letter. A malformed
|
|
727
|
-
* or wrong-drive value is deliberately ignored so drive-relative resolution
|
|
728
|
-
* fails closed instead of guessing a root.
|
|
729
|
-
*
|
|
730
|
-
* Node never surfaces `=X:` keys from the ambient `process.env` (verified:
|
|
731
|
-
* `Object.keys(process.env).filter(k => k.startsWith("="))` is empty), so
|
|
732
|
-
* this provenance is only ever available when a caller supplies it
|
|
733
|
-
* explicitly in an `env` override — e.g. a test, or a future integration
|
|
734
|
-
* that reads it from a lower-level Windows API. That's a real, if narrow,
|
|
735
|
-
* use: keep this path rather than deleting it, but never synthesize a value
|
|
736
|
-
* Node itself can't hand us.
|
|
737
|
-
*/
|
|
738
|
-
function getValidatedPerDriveCwd(env, drive) {
|
|
739
|
-
const value = getWindowsEnvironmentValue(env, `=${drive}:`);
|
|
740
|
-
if (value === undefined || !isDriveAbsolute(value, drive))
|
|
741
|
-
return undefined;
|
|
742
|
-
return path.win32.normalize(value);
|
|
743
|
-
}
|
|
744
|
-
function resolveEffectiveWindowsCwd(cwd, env) {
|
|
745
|
-
if (cwd === undefined) {
|
|
746
|
-
// No caller-supplied cwd: `process.cwd()` IS the effective cwd already —
|
|
747
|
-
// the real, canonical filesystem cwd of THIS process, not a string that
|
|
748
|
-
// needs (re)classifying. On a POSIX CI host it naturally carries no
|
|
749
|
-
// drive letter; that's fine, it never needs one here. Only a
|
|
750
|
-
// CALLER-SUPPLIED cwd string goes through the fully-qualified/rooted/
|
|
751
|
-
// drive-relative classification below — running `process.cwd()`'s own
|
|
752
|
-
// value through `isRootedWindowsPath` was itself a #1201 regression: a
|
|
753
|
-
// drive-less POSIX path (e.g. Linux CI's real cwd) satisfies that
|
|
754
|
-
// predicate too (single leading separator, no drive letter), which then
|
|
755
|
-
// sent it to `resolveRootedWindowsPath` looking for a drive letter on
|
|
756
|
-
// `process.cwd()` that — being the very same drive-less string — could
|
|
757
|
-
// never supply one, resolving to `undefined` (recurring defect shape
|
|
758
|
-
// 2/7: `path.win32.resolve` falls back to `process.cwd()`, which
|
|
759
|
-
// supplies a drive on Windows and none on Linux).
|
|
760
|
-
return path.win32.normalize(process.cwd());
|
|
761
|
-
}
|
|
762
|
-
const requestedDrive = driveLetter(cwd);
|
|
763
|
-
if (requestedDrive !== undefined && !isDriveAbsoluteAnyDrive(cwd)) {
|
|
764
|
-
// `D:foo` — drive-relative to that drive's own current directory.
|
|
765
|
-
const base = getValidatedPerDriveCwd(env, requestedDrive);
|
|
766
|
-
return base === undefined
|
|
767
|
-
? undefined
|
|
768
|
-
: path.win32.normalize(path.win32.resolve(base, cwd));
|
|
769
|
-
}
|
|
770
|
-
if (isFullyQualifiedWindowsPath(cwd))
|
|
771
|
-
return path.win32.normalize(cwd);
|
|
772
|
-
if (isRootedWindowsPath(cwd)) {
|
|
773
|
-
// `\tools` — rooted at the CURRENT drive's root.
|
|
774
|
-
//
|
|
775
|
-
// Note the deliberate asymmetry with `resolveWindowsPathEntry`, which
|
|
776
|
-
// anchors rooted PATH entries to `effectiveCwd`'s drive instead. Both
|
|
777
|
-
// use the SAME rule — "anchor to the best available "current drive"
|
|
778
|
-
// provenance at this point in the pipeline" — they just sit at
|
|
779
|
-
// different points. Here we are *computing* `effectiveCwd`, so it does
|
|
780
|
-
// not exist yet and the process's own cwd is the only provenance
|
|
781
|
-
// available (on real Windows it always carries a drive letter). By the
|
|
782
|
-
// time a PATH entry is resolved, `effectiveCwd` is known and is the
|
|
783
|
-
// strictly better answer, because it is the drive the child will
|
|
784
|
-
// actually run from — anchoring PATH entries to the host drive there
|
|
785
|
-
// would validate one file and execute another. Neither call can fail
|
|
786
|
-
// closed on a drive-less provenance; see `resolveRootedWindowsPath`.
|
|
787
|
-
return resolveRootedWindowsPath(cwd, process.cwd());
|
|
788
|
-
}
|
|
789
|
-
return path.win32.normalize(path.win32.resolve(process.cwd(), cwd));
|
|
790
|
-
}
|
|
791
|
-
function resolveDriveRelativeWindowsPath(value, effectiveCwd, env) {
|
|
792
|
-
const drive = driveLetter(value);
|
|
793
|
-
if (drive === undefined)
|
|
794
|
-
return undefined;
|
|
795
|
-
const cwdDrive = effectiveCwd === undefined ? undefined : driveLetter(effectiveCwd);
|
|
796
|
-
const base = cwdDrive?.toUpperCase() === drive
|
|
797
|
-
? effectiveCwd
|
|
798
|
-
: getValidatedPerDriveCwd(env, drive);
|
|
799
|
-
if (base === undefined)
|
|
800
|
-
return undefined;
|
|
801
|
-
return path.win32.normalize(path.win32.resolve(base, value));
|
|
802
|
-
}
|
|
803
|
-
function resolveWindowsPathEntry(entry, effectiveCwd, env) {
|
|
804
|
-
if (driveLetter(entry) !== undefined && !isDriveAbsoluteAnyDrive(entry)) {
|
|
805
|
-
return resolveDriveRelativeWindowsPath(entry, effectiveCwd, env);
|
|
806
|
-
}
|
|
807
|
-
if (isFullyQualifiedWindowsPath(entry))
|
|
808
|
-
return path.win32.normalize(entry);
|
|
809
|
-
if (isRootedWindowsPath(entry)) {
|
|
810
|
-
// `\tools` — rooted at the effective child cwd's drive root, not the
|
|
811
|
-
// host process's drive (#1201: a rooted PATH entry must resolve on
|
|
812
|
-
// the SAME drive the child will actually run cmd.exe/the resolved
|
|
813
|
-
// binary from). See `resolveEffectiveWindowsCwd`'s rooted branch for
|
|
814
|
-
// why that function anchors to `process.cwd()` instead — same rule,
|
|
815
|
-
// different point in the pipeline. `undefined` here means the cwd
|
|
816
|
-
// itself was unprovable, which is a genuinely different condition
|
|
817
|
-
// from "the provenance carries no drive letter".
|
|
818
|
-
return effectiveCwd === undefined
|
|
819
|
-
? undefined
|
|
820
|
-
: resolveRootedWindowsPath(entry, effectiveCwd);
|
|
821
|
-
}
|
|
822
|
-
if (effectiveCwd === undefined)
|
|
823
|
-
return undefined;
|
|
824
|
-
return path.win32.normalize(path.win32.resolve(effectiveCwd, entry));
|
|
825
|
-
}
|
|
826
|
-
/**
|
|
827
|
-
* Cached PATH + PATHEXT resolution results. The effective child environment is
|
|
828
|
-
* part of the key, not the ambient process environment: one caller's managed
|
|
829
|
-
* bin directory must never poison another caller's resolution. The key also
|
|
830
|
-
* carries the canonical child cwd and Windows `=X:` per-drive cwd entries,
|
|
831
|
-
* because both affect relative PATH and drive-relative lookup. Session-lived
|
|
832
|
-
* (matches this repo's other resolution caches, e.g.
|
|
833
|
-
* `clients/workspace-topology.ts`'s `resetWorkspaceTopology`) — cleared via
|
|
834
|
-
* `resetSafeSpawnWindowsCommandCache()`, wired into `handleSessionStart`. The
|
|
835
|
-
* cache is also count-bounded with oldest-entry eviction because a long-lived
|
|
836
|
-
* process can encounter unbounded cwd/environment combinations.
|
|
837
|
-
*/
|
|
838
|
-
export const WINDOWS_COMMAND_CACHE_MAX_ENTRIES = 256;
|
|
839
|
-
const WINDOWS_COMMAND_NEGATIVE_CACHE_TTL_MS = 1000;
|
|
840
|
-
const windowsCommandCache = new BoundedFifoMap(WINDOWS_COMMAND_CACHE_MAX_ENTRIES);
|
|
841
|
-
/** Reset after session replacement or a successful managed install. */
|
|
842
|
-
export function resetSafeSpawnWindowsCommandCache() {
|
|
843
|
-
windowsCommandCache.clear();
|
|
844
|
-
loggedSpawnClassifications.clear();
|
|
845
|
-
}
|
|
846
|
-
function cacheWindowsCommandResult(key, resolved) {
|
|
847
|
-
// Resolution entries are session-scoped and cheap to recompute. The
|
|
848
|
-
// caller always deletes any stale entry for `key` before reaching here
|
|
849
|
-
// (see resolveWindowsCommandForEnvironment), so this can only grow the
|
|
850
|
-
// map; BoundedFifoMap evicts the oldest insertion first once the map
|
|
851
|
-
// exceeds capacity, so a long-lived process cannot retain every cwd /
|
|
852
|
-
// environment it has ever touched.
|
|
853
|
-
windowsCommandCache.set(key, { resolved, checkedAt: Date.now() });
|
|
854
|
-
}
|
|
855
|
-
function getPathExts(env) {
|
|
856
|
-
const raw = getWindowsEnvironmentValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD";
|
|
857
|
-
return raw
|
|
858
|
-
.split(";")
|
|
859
|
-
.map((ext) => ext.trim().toLowerCase())
|
|
860
|
-
.filter(Boolean);
|
|
861
|
-
}
|
|
862
|
-
function statIsFile(candidate) {
|
|
863
|
-
try {
|
|
864
|
-
return fs.statSync(candidate).isFile();
|
|
865
|
-
}
|
|
866
|
-
catch {
|
|
867
|
-
return false;
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
function resolveWindowsCommandUncached(command, effectiveCwd, env) {
|
|
871
|
-
const pathExts = getPathExts(env);
|
|
872
|
-
const existingExt = path.win32.extname(command).toLowerCase();
|
|
873
|
-
const hasExplicitExt = existingExt.length > 0;
|
|
874
|
-
// A suffix is "known executable" when it's one of the four extensions this
|
|
875
|
-
// resolver actually knows how to run (direct .exe/.com, or the
|
|
876
|
-
// cmd.exe-wrapped .cmd/.bat) OR it's explicitly listed in the caller's own
|
|
877
|
-
// PATHEXT. Anything else with a dot is NOT necessarily "an extension" — a
|
|
878
|
-
// versioned interpreter (`python3.11`, `node-v20.1`) has an `extname()` of
|
|
879
|
-
// `.11`/`.1` that is part of the basename, not a suffix to match exactly
|
|
880
|
-
// (#1201).
|
|
881
|
-
const isKnownExecutableExt = (ext) => WINDOWS_DIRECT_RESOLVABLE_EXTS.has(ext) || pathExts.includes(ext);
|
|
882
|
-
const tryBase = (base) => {
|
|
883
|
-
// Additive, not exclusive: an explicit, known-executable suffix is
|
|
884
|
-
// tried as an exact candidate FIRST (this also lets a caller ask for
|
|
885
|
-
// `foo.cmd` even when the current PATHEXT doesn't happen to list
|
|
886
|
-
// `.CMD`). Whenever the suffix ISN'T a PATHEXT entry — no extension at
|
|
887
|
-
// all, or a versioned/unknown one like `.11` — the PATHEXT-append loop
|
|
888
|
-
// ALSO runs against the full base, so `python3.11` still finds
|
|
889
|
-
// `python3.11.exe`. An unknown suffix (e.g. `foo.txt`) never gets an
|
|
890
|
-
// exact-match short-circuit: that would let an arbitrary
|
|
891
|
-
// non-executable file resolve as "spawnable" and reach the
|
|
892
|
-
// direct-spawn branch downstream.
|
|
893
|
-
if (hasExplicitExt &&
|
|
894
|
-
isKnownExecutableExt(existingExt) &&
|
|
895
|
-
statIsFile(base)) {
|
|
896
|
-
return { resolvedPath: base, ext: existingExt };
|
|
897
|
-
}
|
|
898
|
-
if (!hasExplicitExt || !pathExts.includes(existingExt)) {
|
|
899
|
-
for (const ext of pathExts) {
|
|
900
|
-
const candidate = base + ext;
|
|
901
|
-
if (statIsFile(candidate))
|
|
902
|
-
return { resolvedPath: candidate, ext };
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
return null;
|
|
906
|
-
};
|
|
907
|
-
const hasPathSep = /[\\/]/.test(command);
|
|
908
|
-
// `D:tool.exe` is drive-relative on Windows, not a bare PATH command. It
|
|
909
|
-
// uses the effective cwd only when that cwd is on D:. For another drive we
|
|
910
|
-
// require an explicit, validated `=D:` provenance entry; otherwise this
|
|
911
|
-
// resolver fails closed rather than guessing `D:\\` or searching PATH.
|
|
912
|
-
const hasDrivePrefix = driveLetter(command) !== undefined;
|
|
913
|
-
if (hasPathSep || hasDrivePrefix || isFullyQualifiedWindowsPath(command)) {
|
|
914
|
-
let base;
|
|
915
|
-
if (isFullyQualifiedWindowsPath(command)) {
|
|
916
|
-
base = path.win32.normalize(command);
|
|
917
|
-
}
|
|
918
|
-
else if (hasDrivePrefix) {
|
|
919
|
-
base = resolveDriveRelativeWindowsPath(command, effectiveCwd, env);
|
|
920
|
-
}
|
|
921
|
-
else if (isRootedWindowsPath(command)) {
|
|
922
|
-
base =
|
|
923
|
-
effectiveCwd === undefined
|
|
924
|
-
? undefined
|
|
925
|
-
: resolveRootedWindowsPath(command, effectiveCwd);
|
|
926
|
-
}
|
|
927
|
-
else if (effectiveCwd !== undefined) {
|
|
928
|
-
base = path.win32.normalize(path.win32.resolve(effectiveCwd, command));
|
|
929
|
-
}
|
|
930
|
-
return base === undefined ? null : tryBase(base);
|
|
931
|
-
}
|
|
932
|
-
// A bare command (no path separator) is a plain PATH search. Absolute PATH
|
|
933
|
-
// entries don't need `effectiveCwd` at all, so an unresolvable cwd must
|
|
934
|
-
// only skip the relative entries (handled per-entry in
|
|
935
|
-
// resolveWindowsPathEntry below), not abort the whole search (#1201: a bad
|
|
936
|
-
// cwd shouldn't make an otherwise-resolvable command look "not installed").
|
|
937
|
-
const pathValue = getWindowsEnvironmentValue(env, "PATH") ?? "";
|
|
938
|
-
const pathDirs = pathValue.split(path.win32.delimiter);
|
|
939
|
-
for (const dir of pathDirs) {
|
|
940
|
-
if (!dir)
|
|
941
|
-
continue;
|
|
942
|
-
const resolvedDir = resolveWindowsPathEntry(dir, effectiveCwd, env);
|
|
943
|
-
if (resolvedDir === undefined)
|
|
944
|
-
continue;
|
|
945
|
-
const found = tryBase(path.win32.join(resolvedDir, command));
|
|
946
|
-
if (found)
|
|
947
|
-
return found;
|
|
948
|
-
}
|
|
949
|
-
return null;
|
|
950
|
-
}
|
|
951
|
-
/**
|
|
952
|
-
* Resolve a Windows command using the exact environment that will be passed to
|
|
953
|
-
* the child. Exported as a small platform-independent test/diagnostic seam;
|
|
954
|
-
* callers should pass a Windows-shaped environment and do not need to mutate
|
|
955
|
-
* `process.env` to exercise resolution. Drive-relative commands (`D:tool.exe`)
|
|
956
|
-
* use a same-drive effective cwd, or a validated absolute `=D:` entry for a
|
|
957
|
-
* different drive; without that provenance they fail closed and never search
|
|
958
|
-
* PATH. Relative PATH entries use the canonical effective child cwd.
|
|
959
|
-
*/
|
|
960
|
-
export function resolveWindowsCommandForEnvironment(command, cwd, env) {
|
|
961
|
-
const effectiveCwd = resolveEffectiveWindowsCwd(cwd, env);
|
|
962
|
-
const pathValue = getWindowsEnvironmentValue(env, "PATH");
|
|
963
|
-
const pathExtValue = getWindowsEnvironmentValue(env, "PATHEXT");
|
|
964
|
-
const perDriveCwds = Object.entries(env)
|
|
965
|
-
.flatMap(([key, value]) => /^=[A-Za-z]:$/.test(key)
|
|
966
|
-
? [[key.toLowerCase(), value]]
|
|
967
|
-
: [])
|
|
968
|
-
.sort(([left], [right]) => compareOrdinal(left, right));
|
|
969
|
-
// Keep presence separate from value: absent PATHEXT means the Windows
|
|
970
|
-
// default extension list, while PATHEXT="" means no implicit extensions.
|
|
971
|
-
// The per-drive snapshot is equally important: it is provenance for
|
|
972
|
-
// drive-relative commands and PATH entries, not ambient decoration.
|
|
973
|
-
const cacheKey = JSON.stringify([
|
|
974
|
-
"win32",
|
|
975
|
-
command,
|
|
976
|
-
pathValue === undefined ? ["absent"] : ["present", pathValue],
|
|
977
|
-
pathExtValue === undefined ? ["absent"] : ["present", pathExtValue],
|
|
978
|
-
effectiveCwd === undefined ? ["unresolved"] : ["resolved", effectiveCwd],
|
|
979
|
-
perDriveCwds,
|
|
980
|
-
]);
|
|
981
|
-
const cached = windowsCommandCache.get(cacheKey);
|
|
982
|
-
if (cached) {
|
|
983
|
-
// Positive entries are revalidated on every hit so an executable deleted
|
|
984
|
-
// or replaced mid-session cannot remain spawnable through stale cache
|
|
985
|
-
// state. Negative entries are short-lived to discover external installs;
|
|
986
|
-
// pi-lens-managed installs also reset the cache immediately on success.
|
|
987
|
-
if (cached.resolved && statIsFile(cached.resolved.resolvedPath)) {
|
|
988
|
-
return cached.resolved;
|
|
989
|
-
}
|
|
990
|
-
if (!cached.resolved &&
|
|
991
|
-
Date.now() - cached.checkedAt <= WINDOWS_COMMAND_NEGATIVE_CACHE_TTL_MS) {
|
|
992
|
-
return null;
|
|
993
|
-
}
|
|
994
|
-
windowsCommandCache.delete(cacheKey);
|
|
995
|
-
}
|
|
996
|
-
const resolved = resolveWindowsCommandUncached(command, effectiveCwd, env);
|
|
997
|
-
cacheWindowsCommandResult(cacheKey, resolved);
|
|
998
|
-
return resolved;
|
|
999
|
-
}
|
|
1000
|
-
/** Cached `where`-equivalent for the effective child environment. */
|
|
1001
|
-
function resolveWindowsCommand(command, cwd, env) {
|
|
1002
|
-
return resolveWindowsCommandForEnvironment(command, cwd, env);
|
|
1003
|
-
}
|
|
1004
|
-
function getSpawnEnvironment(overrides) {
|
|
1005
|
-
return process.platform === "win32"
|
|
1006
|
-
? mergeWindowsEnvironment(process.env, overrides)
|
|
1007
|
-
: { ...process.env, ...overrides };
|
|
1008
|
-
}
|
|
1009
|
-
/**
|
|
1010
|
-
* Characters that make a cmd.exe `/c` command line unsound to build from
|
|
1011
|
-
* caller-tainted input: `%`/`!` expand even inside double quotes and cannot
|
|
1012
|
-
* be escaped on a `/c` line (only `%%` works, and only in batch files), `"`
|
|
1013
|
-
* can toggle cmd's quote-parsing state past the doubling convention, and
|
|
1014
|
-
* CR/LF let an argument masquerade as a second command. Reject rather than
|
|
1015
|
-
* attempt to escape these (#817) — silent stripping would just move the
|
|
1016
|
-
* unsoundness, not remove it.
|
|
1017
|
-
*/
|
|
1018
|
-
const CMD_UNSAFE_CHARS = /["%!\r\n]/;
|
|
1019
|
-
/** First caller-tainted value (command or an arg) that can't be safely
|
|
1020
|
-
* passed through the .cmd/.bat cmd.exe wrapper, or `undefined` if all clear. */
|
|
1021
|
-
function findCmdUnsafeValue(command, args) {
|
|
1022
|
-
if (CMD_UNSAFE_CHARS.test(command))
|
|
1023
|
-
return command;
|
|
1024
|
-
return args.find((arg) => CMD_UNSAFE_CHARS.test(arg));
|
|
1025
|
-
}
|
|
1026
|
-
function synthesizeEnoentError(command) {
|
|
1027
|
-
// Shaped like Node's native `spawn <cmd> ENOENT` error (message/code/
|
|
1028
|
-
// syscall/path) so the typed classifier retains the same diagnostic cause
|
|
1029
|
-
// now that Windows resolution happens before spawn instead of inside cmd.exe.
|
|
1030
|
-
const err = new Error(`spawn ${command} ENOENT`);
|
|
1031
|
-
err.code = "ENOENT";
|
|
1032
|
-
err.syscall = "spawn";
|
|
1033
|
-
err.path = command;
|
|
1034
|
-
return err;
|
|
1035
|
-
}
|
|
1036
|
-
/**
|
|
1037
|
-
* One-shot `chcp 65001` before the FIRST direct (.exe/.com) Windows spawn.
|
|
1038
|
-
*
|
|
1039
|
-
* Mechanism: `chcp` mutates the code page of the console the current process
|
|
1040
|
-
* is attached to, and that mutation persists for the console's lifetime —
|
|
1041
|
-
* it is not per-child-process state. The old cmd.exe-wrapper path re-ran
|
|
1042
|
-
* `chcp 65001 >nul 2>&1 &&` on every single spawn, which was always
|
|
1043
|
-
* redundant after the first call; memoizing here (module-level, once per
|
|
1044
|
-
* process) is a strict improvement, not a behavior change. Runs through a
|
|
1045
|
-
* tiny pinned cmd.exe spawn (same `%SystemRoot%\System32\cmd.exe` pin as
|
|
1046
|
-
* `killTree` below) with a static, hardcoded command string — no caller-
|
|
1047
|
-
* tainted data anywhere near it.
|
|
1048
|
-
*/
|
|
1049
|
-
let utf8ConsoleCodePageApplied = false;
|
|
1050
|
-
function ensureUtf8ConsoleCodePageOnce() {
|
|
1051
|
-
if (utf8ConsoleCodePageApplied)
|
|
1052
|
-
return;
|
|
1053
|
-
utf8ConsoleCodePageApplied = true;
|
|
1054
|
-
try {
|
|
1055
|
-
const cmdExe = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
|
|
1056
|
-
spawnSync(cmdExe, ["/d", "/s", "/c", "chcp 65001 >nul 2>&1"], {
|
|
1057
|
-
shell: false,
|
|
1058
|
-
windowsHide: true,
|
|
1059
|
-
// #1980 population sweep: a synchronous spawn with no timeout parks
|
|
1060
|
-
// the event loop for as long as the child takes, and nothing here
|
|
1061
|
-
// bounds that. `chcp` is normally instant, but this runs before the
|
|
1062
|
-
// FIRST real spawn of the process, which is exactly when a cold or
|
|
1063
|
-
// antivirus-throttled cmd.exe is slowest. Matches the 5s bound every
|
|
1064
|
-
// other synchronous child-process site in this repo already carries.
|
|
1065
|
-
timeout: 5000,
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1068
|
-
catch {
|
|
1069
|
-
// Best-effort: worst case is non-ASCII tool output decoded incorrectly, not a
|
|
1070
|
-
// spawn failure — never let this block the real spawn.
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
/**
|
|
1074
|
-
* Test-only seam: clear the one-shot `chcp` memoization.
|
|
1075
|
-
*
|
|
1076
|
-
* No test observes it today — the memoized work only runs inside a real
|
|
1077
|
-
* Windows direct spawn, so exercising it would mean a Windows-only test, and
|
|
1078
|
-
* the state it guards is a console code page, not behavior any assertion
|
|
1079
|
-
* depends on. This is kept purely so a future Windows-only test (or a second
|
|
1080
|
-
* caller of `ensureUtf8ConsoleCodePageOnce`) can reset process-lifetime state
|
|
1081
|
-
* without reaching into the module; it was dropped once as unrelated scope
|
|
1082
|
-
* creep in an earlier #1199 revision and restored on review. Do not read the
|
|
1083
|
-
* export as evidence of existing coverage (#1201).
|
|
1084
|
-
*/
|
|
1085
|
-
export function resetUtf8ConsoleCodePageStateForTests() {
|
|
1086
|
-
utf8ConsoleCodePageApplied = false;
|
|
1087
|
-
}
|
|
1088
|
-
// #1656: post-exit pipe-idle wait constants. Node's "close" event only fires
|
|
1089
|
-
// once every stdio fd referencing the child has been released — a
|
|
1090
|
-
// daemonized descendant that inherited our stdout/stderr pipe (the Windows
|
|
1091
|
-
// no-job-object case) can hold that fd open forever, hanging "close" even
|
|
1092
|
-
// though the child we spawned is long dead. A fixed post-exit deadline
|
|
1093
|
-
// avoids that hang but truncates a legitimately still-streaming child. The
|
|
1094
|
-
// idle-grace construction (adopted from pi's `waitForChildProcess`) does
|
|
1095
|
-
// neither: after "exit", wait for the pipes to go quiet — no data for
|
|
1096
|
-
// EXIT_PIPE_IDLE_GRACE_MS, re-armed on every chunk — so a quiet inherited
|
|
1097
|
-
// handle still releases after one grace window while an actively-writing
|
|
1098
|
-
// descendant keeps being read. EXIT_PIPE_IDLE_MAX_WAIT_MS caps the total
|
|
1099
|
-
// extra wait so a pathological, never-quiet descendant can't extend the
|
|
1100
|
-
// caller's overall budget unboundedly.
|
|
1101
|
-
const EXIT_PIPE_IDLE_GRACE_MS = 100;
|
|
1102
|
-
const EXIT_PIPE_IDLE_MAX_WAIT_MS = 2000;
|
|
1103
|
-
/**
|
|
1104
|
-
* #2968: how long past its own deadline this spawn's CPU/RSS sampler may keep
|
|
1105
|
-
* polling. Everything this function does to end a child after the deadline
|
|
1106
|
-
* fits inside it: `killTree`'s SIGTERM plus its 1000ms SIGKILL escalation (or
|
|
1107
|
-
* Windows' `taskkill /F /T`), then at most EXIT_PIPE_IDLE_MAX_WAIT_MS of
|
|
1108
|
-
* pipe-idle wait. A child still alive past deadline + this grace has outlived
|
|
1109
|
-
* every teardown available here, so polling it further only buys the Windows
|
|
1110
|
-
* process pile-up the report measured — and `stop()` still returns whatever
|
|
1111
|
-
* the sampler gathered before the cap.
|
|
1112
|
-
*/
|
|
1113
|
-
const SPAWN_SAMPLER_TEARDOWN_GRACE_MS = 5_000;
|
|
1114
|
-
// ============================================================================
|
|
1115
|
-
// ASYNC VERSION (Recommended - Non-blocking)
|
|
1116
|
-
// ============================================================================
|
|
1117
|
-
/**
|
|
1118
|
-
* Async spawn with timeout and proper process cleanup.
|
|
1119
|
-
*
|
|
1120
|
-
* Unlike spawnSync, this:
|
|
1121
|
-
* - Doesn't block the event loop
|
|
1122
|
-
* - Kills the process on timeout (preventing zombies)
|
|
1123
|
-
* - Supports cancellation via AbortSignal
|
|
1124
|
-
*
|
|
1125
|
-
* ## Why this bound is NOT `bounded()` — the one documented exception (#2523)
|
|
1126
|
-
*
|
|
1127
|
-
* #2523 slice 2 folded every other "deadline AND abort signal" implementation
|
|
1128
|
-
* in the tree onto `clients/deadline-utils.ts#bounded`. This one stays, and it
|
|
1129
|
-
* is not a copy of the same idea:
|
|
1130
|
-
*
|
|
1131
|
-
* `bounded()` ABANDONS a promise — it stops waiting and lets the work run on
|
|
1132
|
-
* unobserved, which is safe precisely because everything it wraps is in-process
|
|
1133
|
-
* (a dynamic import, an LSP round trip, a `stat`). This function's work is a
|
|
1134
|
-
* CHILD PROCESS, and abandoning one leaks it. So its timer does not settle a
|
|
1135
|
-
* race at all: `timeoutId` sets `timedOut`, then runs `killTree()` — SIGTERM,
|
|
1136
|
-
* an escalation timer, SIGKILL, and on Windows a `taskkill /T` of the whole
|
|
1137
|
-
* tree — and the promise settles only when the child's own `close` arrives (or
|
|
1138
|
-
* the escalation gives up). The result it produces carries the teardown
|
|
1139
|
-
* EVIDENCE (`timeoutTeardown`, #2010): which signal killed it, whether the
|
|
1140
|
-
* tree actually died, how long it took. Replacing that with `bounded()` would
|
|
1141
|
-
* return `undefined` at the deadline and leave a live `vitest`/`gradle` behind
|
|
1142
|
-
* — the orphan class #2504 measured as 59 concurrent spawns starving a turn.
|
|
1143
|
-
*
|
|
1144
|
-
* The rule the exception preserves: a bound that can CANCEL its work must
|
|
1145
|
-
* cancel it. `bounded()` is for work that cannot be cancelled; this is not
|
|
1146
|
-
* that. Anything wrapping THIS function still gets `bounded()` around its own
|
|
1147
|
-
* await if it needs one.
|
|
1148
|
-
*
|
|
1149
|
-
* @example
|
|
1150
|
-
* const result = await safeSpawnAsync("npm", ["test"], { timeout: 30000 });
|
|
1151
|
-
* if (result.error) console.error("Failed:", result.error);
|
|
1152
|
-
*/
|
|
1153
|
-
export async function safeSpawnAsync(command, args, options) {
|
|
1154
|
-
const configuredTimeout = options?.timeout !== undefined &&
|
|
1155
|
-
Number.isFinite(options.timeout) &&
|
|
1156
|
-
options.timeout >= 0
|
|
1157
|
-
? options.timeout
|
|
1158
|
-
: 30000;
|
|
1159
|
-
const deadlineRemaining = options?.deadlineAt !== undefined && Number.isFinite(options.deadlineAt)
|
|
1160
|
-
? options.deadlineAt - Date.now()
|
|
1161
|
-
: Number.POSITIVE_INFINITY;
|
|
1162
|
-
const timeout = Math.max(0, Math.min(configuredTimeout, deadlineRemaining));
|
|
1163
|
-
// Fall back to the current turn's ambient signal (set from ctx.signal) so an
|
|
1164
|
-
// Esc/abort mid-turn cancels dispatches that didn't thread a signal of their
|
|
1165
|
-
// own — unless the caller opts out (installs, which must run to completion).
|
|
1166
|
-
const abortSignal = options?.signal ??
|
|
1167
|
-
(options?.ignoreAmbientSignal ? undefined : ambientAbortSignal);
|
|
1168
|
-
return new Promise((resolve) => {
|
|
1169
|
-
// Check for early abort
|
|
1170
|
-
if (abortSignal?.aborted) {
|
|
1171
|
-
const cause = new Error("Spawn aborted before start");
|
|
1172
|
-
resolve({
|
|
1173
|
-
stdout: "",
|
|
1174
|
-
stderr: "",
|
|
1175
|
-
status: null,
|
|
1176
|
-
error: cause,
|
|
1177
|
-
failure: "aborted",
|
|
1178
|
-
spawnFailure: new SpawnFailureError("killed", cause.message, cause),
|
|
1179
|
-
});
|
|
1180
|
-
return;
|
|
1181
|
-
}
|
|
1182
|
-
if (timeout <= 0) {
|
|
1183
|
-
const cause = new Error(`Process timed out after ${timeout}ms`);
|
|
1184
|
-
resolve({
|
|
1185
|
-
stdout: "",
|
|
1186
|
-
stderr: "",
|
|
1187
|
-
status: null,
|
|
1188
|
-
error: cause,
|
|
1189
|
-
failure: "timeout",
|
|
1190
|
-
spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
|
|
1191
|
-
});
|
|
1192
|
-
return;
|
|
1193
|
-
}
|
|
1194
|
-
let stdout = "";
|
|
1195
|
-
let stderr = "";
|
|
1196
|
-
let timedOut = false;
|
|
1197
|
-
let aborted = false;
|
|
1198
|
-
let killed = false;
|
|
1199
|
-
let outputTruncated = false;
|
|
1200
|
-
let killedForOutputCap = false;
|
|
1201
|
-
// #3375: bytes the child actually EMITTED (not what survived the cap) —
|
|
1202
|
-
// the one number the field crash report could not supply, so the ledger
|
|
1203
|
-
// row below can name how chatty the offending command really was.
|
|
1204
|
-
let observedOutputBytes = 0;
|
|
1205
|
-
// #3375: bytes RETAINED so far, kept incrementally. The cap check used to
|
|
1206
|
-
// re-measure `Buffer.byteLength(stdout) + Buffer.byteLength(stderr)` on
|
|
1207
|
-
// every chunk, which flattens both accumulated strings each time — O(n^2)
|
|
1208
|
-
// in total output. Harmless while only 12 of 110 call sites passed a cap;
|
|
1209
|
-
// with the cap now on by default it would tax every spawn (measured: a
|
|
1210
|
-
// 32 MiB child resolved in 7944 ms before this counter, 469 ms after).
|
|
1211
|
-
// Equal to the two byte lengths by construction: before
|
|
1212
|
-
// truncation `stdout`/`stderr` are exactly the appended texts, and after
|
|
1213
|
-
// it the branch that renders from the retained head/tail never reads it.
|
|
1214
|
-
let retainedOutputBytes = 0;
|
|
1215
|
-
// #3375: latched the first time a chunk handler throws. Later chunks
|
|
1216
|
-
// return immediately, so the same fault cannot be re-entered and
|
|
1217
|
-
// `refreshRetainedOutputs` can never overwrite the bytes already kept.
|
|
1218
|
-
let outputHandlerFailed = false;
|
|
1219
|
-
let streamingMatch = false;
|
|
1220
|
-
// #1651 review: a single boolean the close/error handlers both check
|
|
1221
|
-
// AND set, so whichever one decides the outcome first wins outright —
|
|
1222
|
-
// the DECISION itself is shape-based (see the `close` handler below),
|
|
1223
|
-
// this flag only stops the loser from resolving a second time.
|
|
1224
|
-
let resolved = false;
|
|
1225
|
-
// The most recent 'error' Error, captured the instant the event fires
|
|
1226
|
-
// regardless of whether that handler goes on to resolve. The `close`
|
|
1227
|
-
// handler's shape-based branch (code === null || code < 0) reads this
|
|
1228
|
-
// when it decides FIRST, so a genuinely informative ENOENT/EACCES/etc.
|
|
1229
|
-
// Error is used when it happens to already be available, instead of
|
|
1230
|
-
// always falling back to a generic synthesized one.
|
|
1231
|
-
let pendingSpawnError;
|
|
1232
|
-
// #1109: the non-Windows SIGTERM→SIGKILL escalation timer (armed in
|
|
1233
|
-
// killTree below). Stored per-call (never shared) so the close/error
|
|
1234
|
-
// handlers can clear it if the child exits before it fires — same
|
|
1235
|
-
// uncleared-race-timeout class as the LSP client-wait leak (#1097):
|
|
1236
|
-
// a ref'd 1s timer that outlives the child it was escalating against
|
|
1237
|
-
// would keep a one-shot `pi --print` process alive for up to 1s.
|
|
1238
|
-
let escalationTimer;
|
|
1239
|
-
let teardownStartedAtMs;
|
|
1240
|
-
let teardownEscalated = false;
|
|
1241
|
-
// #2010: teardown evidence for the spawn's OWN timeout. The wall clock
|
|
1242
|
-
// starts when the budget expires and the tree-kill is issued, and stops
|
|
1243
|
-
// when the process tree is observed dead (`await killPromise` settles)
|
|
1244
|
-
// - measured SEPARATELY from the budget itself, so a slow teardown is
|
|
1245
|
-
// visible instead of silently inflating "how long the tool ran".
|
|
1246
|
-
// `teardownEscalated` names whether SIGTERM sufficed or the SIGKILL
|
|
1247
|
-
// escalation fired (POSIX; Windows taskkill /F is always forceful).
|
|
1248
|
-
// #1114: `child.killed` is set by Node the moment `kill()` successfully
|
|
1249
|
-
// SENDS a signal, not when the child actually dies — so gating the
|
|
1250
|
-
// escalation on `!child.killed` right after a successful `SIGTERM` send
|
|
1251
|
-
// is always false and the SIGKILL branch is unreachable. Track observed
|
|
1252
|
-
// death via the close/error handlers instead (set synchronously, before
|
|
1253
|
-
// any `await`, so a timer firing during the close handler's `await
|
|
1254
|
-
// killPromise` still observes the flag correctly).
|
|
1255
|
-
let closed = false;
|
|
1256
|
-
// #1656: set only while the post-exit idle-pipe wait is active (see
|
|
1257
|
-
// waitForPipeIdle below); the stdout/stderr "data" handlers call this to
|
|
1258
|
-
// re-arm the grace timer on every chunk. `undefined` before exit / after
|
|
1259
|
-
// the wait settles, so the calls below are no-ops outside that window.
|
|
1260
|
-
let rearmIdleGrace;
|
|
1261
|
-
// #3375: never `undefined`. An omitted or unusable cap resolves to the
|
|
1262
|
-
// module default instead of to unbounded retention, so `appendOutput`
|
|
1263
|
-
// below has no arm that concatenates without a ceiling.
|
|
1264
|
-
const callerCap = options?.maxOutputBytes;
|
|
1265
|
-
const capFromCaller = callerCap !== undefined && Number.isFinite(callerCap) && callerCap > 0;
|
|
1266
|
-
const maxOutputBytes = capFromCaller
|
|
1267
|
-
? Math.floor(callerCap)
|
|
1268
|
-
: DEFAULT_MAX_OUTPUT_BYTES;
|
|
1269
|
-
const outputTruncationMarker = "\n...[output truncated]...\n";
|
|
1270
|
-
let retainedHead = [];
|
|
1271
|
-
let retainedTail = [];
|
|
1272
|
-
let retainedTailBytes = 0;
|
|
1273
|
-
let retainedTailLimit = 0;
|
|
1274
|
-
let truncationStream = "stderr";
|
|
1275
|
-
const streamingMatcher = options?.matchWhileStreaming;
|
|
1276
|
-
const streamingCarryBytes = streamingMatcher
|
|
1277
|
-
? Math.max(0, streamingMatcher.source.length - 1)
|
|
1278
|
-
: 0;
|
|
1279
|
-
const streamingCarry = {
|
|
1280
|
-
stdout: "",
|
|
1281
|
-
stderr: "",
|
|
1282
|
-
};
|
|
1283
|
-
const bytePrefix = (text, bytes) => Buffer.from(text).subarray(0, bytes).toString();
|
|
1284
|
-
const byteSuffix = (text, bytes) => {
|
|
1285
|
-
const buffer = Buffer.from(text);
|
|
1286
|
-
return buffer.subarray(Math.max(0, buffer.byteLength - bytes)).toString();
|
|
1287
|
-
};
|
|
1288
|
-
const appendTail = (part, maxBytes) => {
|
|
1289
|
-
if (maxBytes <= 0)
|
|
1290
|
-
return;
|
|
1291
|
-
retainedTail.push({ stream: part.stream, text: part.text });
|
|
1292
|
-
retainedTailBytes += Buffer.byteLength(part.text);
|
|
1293
|
-
while (retainedTailBytes > maxBytes && retainedTail.length > 0) {
|
|
1294
|
-
const first = retainedTail[0];
|
|
1295
|
-
const excess = retainedTailBytes - maxBytes;
|
|
1296
|
-
const firstBytes = Buffer.byteLength(first.text);
|
|
1297
|
-
if (firstBytes <= excess) {
|
|
1298
|
-
retainedTail.shift();
|
|
1299
|
-
retainedTailBytes -= firstBytes;
|
|
1300
|
-
}
|
|
1301
|
-
else {
|
|
1302
|
-
first.text = byteSuffix(first.text, firstBytes - excess);
|
|
1303
|
-
retainedTailBytes -= excess;
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
};
|
|
1307
|
-
const renderOutput = (stream) => {
|
|
1308
|
-
const head = retainedHead
|
|
1309
|
-
.filter((part) => part.stream === stream)
|
|
1310
|
-
.map((part) => part.text)
|
|
1311
|
-
.join("");
|
|
1312
|
-
const tail = retainedTail
|
|
1313
|
-
.filter((part) => part.stream === stream)
|
|
1314
|
-
.map((part) => part.text)
|
|
1315
|
-
.join("");
|
|
1316
|
-
const marker = stream === truncationStream ? outputTruncationMarker : "";
|
|
1317
|
-
return head + marker + tail;
|
|
1318
|
-
};
|
|
1319
|
-
const refreshRetainedOutputs = () => {
|
|
1320
|
-
stdout = renderOutput("stdout");
|
|
1321
|
-
stderr = renderOutput("stderr");
|
|
1322
|
-
};
|
|
1323
|
-
const appendOutput = (stream, current, chunk) => {
|
|
1324
|
-
const text = typeof chunk === "string" ? chunk : chunk.toString();
|
|
1325
|
-
if (outputTruncated) {
|
|
1326
|
-
appendTail({ stream, text }, retainedTailLimit);
|
|
1327
|
-
truncationStream = stream;
|
|
1328
|
-
return renderOutput(stream);
|
|
1329
|
-
}
|
|
1330
|
-
const bytes = Buffer.byteLength(text);
|
|
1331
|
-
if (retainedOutputBytes + bytes <= maxOutputBytes) {
|
|
1332
|
-
retainedOutputBytes += bytes;
|
|
1333
|
-
return current + text;
|
|
1334
|
-
}
|
|
1335
|
-
outputTruncated = true;
|
|
1336
|
-
truncationStream = stream;
|
|
1337
|
-
const available = Math.max(0, maxOutputBytes - Buffer.byteLength(outputTruncationMarker));
|
|
1338
|
-
const headBytes = Math.floor(available / 2);
|
|
1339
|
-
const tailBytes = available - headBytes;
|
|
1340
|
-
retainedTailLimit = tailBytes;
|
|
1341
|
-
const parts = [
|
|
1342
|
-
{ stream: "stdout", text: stdout },
|
|
1343
|
-
{ stream: "stderr", text: stderr },
|
|
1344
|
-
{ stream, text },
|
|
1345
|
-
];
|
|
1346
|
-
let headRemaining = headBytes;
|
|
1347
|
-
for (const part of parts) {
|
|
1348
|
-
if (headRemaining <= 0)
|
|
1349
|
-
break;
|
|
1350
|
-
const kept = bytePrefix(part.text, headRemaining);
|
|
1351
|
-
if (kept)
|
|
1352
|
-
retainedHead.push({ stream: part.stream, text: kept });
|
|
1353
|
-
headRemaining -= Buffer.byteLength(kept);
|
|
1354
|
-
}
|
|
1355
|
-
let tailRemaining = tailBytes;
|
|
1356
|
-
for (let index = parts.length - 1; index >= 0 && tailRemaining > 0; index--) {
|
|
1357
|
-
const part = parts[index];
|
|
1358
|
-
const kept = byteSuffix(part.text, tailRemaining);
|
|
1359
|
-
if (kept) {
|
|
1360
|
-
retainedTail.unshift({ stream: part.stream, text: kept });
|
|
1361
|
-
tailRemaining -= Buffer.byteLength(kept);
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
retainedTailBytes = tailBytes - tailRemaining;
|
|
1365
|
-
return renderOutput(stream);
|
|
1366
|
-
};
|
|
1367
|
-
const matchStreamingChunk = (stream, chunk) => {
|
|
1368
|
-
if (streamingMatch || !streamingMatcher)
|
|
1369
|
-
return;
|
|
1370
|
-
const text = typeof chunk === "string" ? chunk : chunk.toString();
|
|
1371
|
-
streamingMatcher.lastIndex = 0;
|
|
1372
|
-
streamingMatch = streamingMatcher.test(streamingCarry[stream] + text);
|
|
1373
|
-
if (streamingCarryBytes > 0) {
|
|
1374
|
-
streamingCarry[stream] = Buffer.from(streamingCarry[stream] + text)
|
|
1375
|
-
.subarray(-streamingCarryBytes)
|
|
1376
|
-
.toString();
|
|
1377
|
-
}
|
|
1378
|
-
};
|
|
1379
|
-
// Spawn the process (non-blocking). Keeping Node's `shell` option false
|
|
1380
|
-
// is important on every path here: shell:true concatenates tainted
|
|
1381
|
-
// arguments into a command line before spawning (CodeQL #17 / CWE-78).
|
|
1382
|
-
//
|
|
1383
|
-
// On Windows (#817): resolve `command` ourselves (PATH + PATHEXT walk,
|
|
1384
|
-
// cached) instead of always routing through cmd.exe.
|
|
1385
|
-
// - .exe/.com → spawn the resolved path directly, args stay a real
|
|
1386
|
-
// array, no shell involved at all (injection-proof).
|
|
1387
|
-
// - .cmd/.bat → these are scripts cmd.exe must interpret (npm shims
|
|
1388
|
-
// are the common case), so keep the wrapper, but pin the
|
|
1389
|
-
// interpreter to %SystemRoot%\System32\cmd.exe (never trust
|
|
1390
|
-
// ComSpec — CodeQL #18) and VALIDATE args instead of trying to
|
|
1391
|
-
// escape them: reject anything containing `"`, `%`, `!`, or CR/LF
|
|
1392
|
-
// rather than spawn with an unsound escape.
|
|
1393
|
-
// - unresolvable → synthesize an ENOENT-shaped error instead of
|
|
1394
|
-
// letting cmd.exe report "not recognized" from inside a shell.
|
|
1395
|
-
const isWindows = process.platform === "win32";
|
|
1396
|
-
const spawnEnv = getSpawnEnvironment(options?.env);
|
|
1397
|
-
// The cwd handed to the CHILD process doesn't need OUR validation —
|
|
1398
|
-
// Windows resolves it natively, exactly as it did before #817 ever
|
|
1399
|
-
// touched this file. A drive-relative cwd (`D:work`) without a
|
|
1400
|
-
// validated `=D:` env entry can't be canonicalized by us (Node never
|
|
1401
|
-
// surfaces `=X:` keys from the ambient environment — see
|
|
1402
|
-
// `getValidatedPerDriveCwd`'s doc comment), but that is a gap in OUR
|
|
1403
|
-
// provenance, not evidence the cwd itself is bad. Fall back to the raw
|
|
1404
|
-
// value instead of failing the whole spawn before ever attempting it
|
|
1405
|
-
// (#1201) — command resolution just below still fails closed for any
|
|
1406
|
-
// relative PATH entry that would need this cwd to be canonical.
|
|
1407
|
-
// No `?? process.cwd()` third arm: `resolveEffectiveWindowsCwd(undefined,
|
|
1408
|
-
// env)` always returns a string, so the only way to reach `undefined`
|
|
1409
|
-
// here is a DEFINED-but-unprovable `options.cwd`, which `?? options?.cwd`
|
|
1410
|
-
// already covers. A `process.cwd()` arm would be unreachable, and it
|
|
1411
|
-
// would read like a live silent-wrong-directory hazard (#1201).
|
|
1412
|
-
const spawnCwd = isWindows
|
|
1413
|
-
? (resolveEffectiveWindowsCwd(options?.cwd, spawnEnv) ?? options?.cwd)
|
|
1414
|
-
: options?.cwd;
|
|
1415
|
-
let spawnCmd = command;
|
|
1416
|
-
let spawnArgs = args;
|
|
1417
|
-
let windowsVerbatimArguments = false;
|
|
1418
|
-
let resolutionError;
|
|
1419
|
-
if (isWindows) {
|
|
1420
|
-
// Pass the ORIGINAL (unresolved) cwd here, not `spawnCwd` above — this
|
|
1421
|
-
// seam intentionally fails closed on unprovable drive-relative
|
|
1422
|
-
// provenance (matches `resolveWindowsCommandForEnvironment`'s
|
|
1423
|
-
// documented contract and its dedicated test coverage), independent
|
|
1424
|
-
// of the passthrough fallback the actual child cwd gets above.
|
|
1425
|
-
const resolved = resolveWindowsCommand(command, options?.cwd, spawnEnv);
|
|
1426
|
-
if (!resolved) {
|
|
1427
|
-
resolutionError = synthesizeEnoentError(command);
|
|
1428
|
-
}
|
|
1429
|
-
else if (resolved.ext === ".cmd" || resolved.ext === ".bat") {
|
|
1430
|
-
// Validate the RESOLVED path (what actually gets interpolated
|
|
1431
|
-
// into the /c line via buildWindowsShellCommand below), not the
|
|
1432
|
-
// caller's original `command` string — a resolved path
|
|
1433
|
-
// containing `%`/`!` would otherwise reach the shell unvalidated.
|
|
1434
|
-
const unsafeValue = findCmdUnsafeValue(resolved.resolvedPath, args);
|
|
1435
|
-
if (unsafeValue !== undefined) {
|
|
1436
|
-
resolutionError = new Error(`Refusing to spawn "${resolved.resolvedPath}" via cmd.exe: ` +
|
|
1437
|
-
`${JSON.stringify(unsafeValue)} contains a character ("` +
|
|
1438
|
-
`, %, !, or CR/LF) that cannot be safely escaped on a ` +
|
|
1439
|
-
`cmd.exe /c command line (CWE-78, #817). Rename/quote the ` +
|
|
1440
|
-
"value or invoke the tool without going through cmd.exe.");
|
|
1441
|
-
}
|
|
1442
|
-
else {
|
|
1443
|
-
// Pin the interpreter — never trust ComSpec/COMSPEC (#18): an
|
|
1444
|
-
// env-controlled ComSpec could point anywhere.
|
|
1445
|
-
spawnCmd = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
|
|
1446
|
-
spawnArgs = [
|
|
1447
|
-
"/d",
|
|
1448
|
-
"/s",
|
|
1449
|
-
"/c",
|
|
1450
|
-
buildWindowsShellCommand(resolved.resolvedPath, args),
|
|
1451
|
-
];
|
|
1452
|
-
// The `/c` payload is already quoted by buildWindowsShellCommand.
|
|
1453
|
-
// Prevent Node from escaping those quotes a second time when it
|
|
1454
|
-
// builds cmd.exe's Windows command line.
|
|
1455
|
-
windowsVerbatimArguments = true;
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
else {
|
|
1459
|
-
// .exe / .com: direct spawn, no cmd.exe anywhere in the picture.
|
|
1460
|
-
ensureUtf8ConsoleCodePageOnce();
|
|
1461
|
-
spawnCmd = resolved.resolvedPath;
|
|
1462
|
-
spawnArgs = args;
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
if (resolutionError) {
|
|
1466
|
-
void classifySpawnFailure(resolutionError, {
|
|
1467
|
-
command,
|
|
1468
|
-
cwd: options?.cwd,
|
|
1469
|
-
}).then((spawnFailure) => resolve({
|
|
1470
|
-
stdout: "",
|
|
1471
|
-
stderr: "",
|
|
1472
|
-
status: null,
|
|
1473
|
-
error: resolutionError,
|
|
1474
|
-
failure: "spawn",
|
|
1475
|
-
spawnFailure,
|
|
1476
|
-
}));
|
|
1477
|
-
return;
|
|
1478
|
-
}
|
|
1479
|
-
let child;
|
|
1480
|
-
// #2026: on POSIX, run the child in its OWN process group (detached).
|
|
1481
|
-
// killTree/killPidTreeSync then signal the whole GROUP (-pid), which
|
|
1482
|
-
// makes grandchildren (npm -> node, sh -> sleep, etc.) die with their
|
|
1483
|
-
// tool instead of surviving every timeout as orphans. Detachment stops
|
|
1484
|
-
// direct terminal-signal delivery, so EVERY POSIX pid registers for
|
|
1485
|
-
// lifetime cleanup - pi's signal/exit handlers forward the kill,
|
|
1486
|
-
// preserving die-with-host semantics.
|
|
1487
|
-
const posixProcessGroup = process.platform !== "win32";
|
|
1488
|
-
try {
|
|
1489
|
-
child = spawn(spawnCmd, spawnArgs, {
|
|
1490
|
-
cwd: spawnCwd,
|
|
1491
|
-
env: spawnEnv,
|
|
1492
|
-
windowsHide: true,
|
|
1493
|
-
shell: false,
|
|
1494
|
-
windowsVerbatimArguments,
|
|
1495
|
-
detached: posixProcessGroup,
|
|
1496
|
-
});
|
|
1497
|
-
}
|
|
1498
|
-
catch (err) {
|
|
1499
|
-
// A SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL — the
|
|
1500
|
-
// pidusage bug class, #533) must NOT reject this Promise: every caller
|
|
1501
|
-
// relies on safeSpawnAsync never rejecting (they inspect result.error),
|
|
1502
|
-
// and many invoke it fire-and-forget in best-effort/background paths, so
|
|
1503
|
-
// a rejection here could surface as an unhandledRejection that crashes
|
|
1504
|
-
// the host. Resolve the failure gracefully instead — same contract as an
|
|
1505
|
-
// asynchronously-emitted `'error'` event (handled below).
|
|
1506
|
-
const cause = toError(err);
|
|
1507
|
-
void classifySpawnFailure(cause, { command, cwd: options?.cwd }).then((spawnFailure) => resolve({
|
|
1508
|
-
stdout: "",
|
|
1509
|
-
stderr: "",
|
|
1510
|
-
status: null,
|
|
1511
|
-
error: cause,
|
|
1512
|
-
failure: "spawn",
|
|
1513
|
-
spawnFailure,
|
|
1514
|
-
}));
|
|
1515
|
-
return;
|
|
1516
|
-
}
|
|
1517
|
-
// Keep historical open stdin by default. An opted-in payload is written
|
|
1518
|
-
// completely and followed by EOF; input: "" closes stdin immediately.
|
|
1519
|
-
if (options?.input !== undefined) {
|
|
1520
|
-
child.stdin?.on("error", () => { });
|
|
1521
|
-
child.stdin?.end(options.input);
|
|
1522
|
-
}
|
|
1523
|
-
// #2042: ownership is a SPAWN-TIME fact — a real child is alive with
|
|
1524
|
-
// `PPid == ours` the instant `spawn` returns, while a test double's
|
|
1525
|
-
// invented pid never is. Resolve it once, here, and reuse the verdict:
|
|
1526
|
-
// the group escalation below must still fire after the direct child
|
|
1527
|
-
// dies (#2027: a SIGTERM-hardy grandchild keeps the group alive), so it
|
|
1528
|
-
// cannot re-read `/proc` at kill time.
|
|
1529
|
-
const ownsChildPid = isOwnLiveChild(child.pid, "safe-spawn-register");
|
|
1530
|
-
if (ownsChildPid && (posixProcessGroup || options?.lifetimeCoupled)) {
|
|
1531
|
-
// #2026: POSIX registers unconditionally - detached children no
|
|
1532
|
-
// longer receive terminal signals directly, so the lifetime
|
|
1533
|
-
// cleanup's signal forwarding IS their die-with-host path.
|
|
1534
|
-
installLifetimeCleanup();
|
|
1535
|
-
lifetimeState.pids.add(child.pid);
|
|
1536
|
-
}
|
|
1537
|
-
// #620: bracket this spawn's lifetime with a short-interval CPU/RSS poll
|
|
1538
|
-
// (started right here, stopped in finalize() below) so transient
|
|
1539
|
-
// analyzer children (jscpd, knip, madge, gitleaks, etc.) — which live too
|
|
1540
|
-
// briefly for heartbeat-cadence sampling to reliably catch — still get a
|
|
1541
|
-
// peak/average resource reading. `startSpawnUsageSampler` itself is
|
|
1542
|
-
// best-effort/never-throws by design, but this call site wraps it anyway
|
|
1543
|
-
// (belt and suspenders: the sampling seam must never be the reason a real
|
|
1544
|
-
// spawn fails) with a no-op fallback sampler.
|
|
1545
|
-
//
|
|
1546
|
-
// #2968: the sampler's polling lifetime is bounded by THIS spawn's own
|
|
1547
|
-
// deadline plus the teardown grace, not by the child's willingness to
|
|
1548
|
-
// exit. `finalize`/the `error` handler still stop it on every settle
|
|
1549
|
-
// path, but none of those paths exist for a child that never settles —
|
|
1550
|
-
// a hung `.cmd` shim whose tree survives `taskkill /F /T` kept polling
|
|
1551
|
-
// for 5-6 hours in the report.
|
|
1552
|
-
let usageSampler;
|
|
1553
|
-
try {
|
|
1554
|
-
usageSampler = startSpawnUsageSampler(
|
|
1555
|
-
// The poll CADENCE is the sampler's own policy (it owns the backoff
|
|
1556
|
-
// too); the DEADLINE is this function's. Passing `undefined` rather
|
|
1557
|
-
// than importing the cadence constant keeps this module's view of
|
|
1558
|
-
// the sampler at one export, which is also what every existing
|
|
1559
|
-
// `vi.mock` of it provides.
|
|
1560
|
-
child.pid, undefined, timeout + SPAWN_SAMPLER_TEARDOWN_GRACE_MS);
|
|
1561
|
-
}
|
|
1562
|
-
catch {
|
|
1563
|
-
usageSampler = { stop: () => null };
|
|
1564
|
-
}
|
|
1565
|
-
const resourceLabel = options?.resourceLabel ?? command;
|
|
1566
|
-
let killFailed = false;
|
|
1567
|
-
/** Send one signal. INVARIANT (#3375 r3 / M3384-2): every signal SEND in
|
|
1568
|
-
* `killTree` goes through this function. Exactly two `try`/`catch`
|
|
1569
|
-
* blocks remain in there and neither contains a bare send: the Windows
|
|
1570
|
-
* `await` guard around the taskkill child (its `catch` body sends only
|
|
1571
|
-
* through `trySend`), and the group LIVENESS PROBE, whose `catch`
|
|
1572
|
-
* computes a predicate rather than sending anything — the same shape as
|
|
1573
|
-
* the module-level `groupIsGone`. Returns false instead of throwing, and
|
|
1574
|
-
* records nothing itself: a refused signal is often ORDINARY (a negative-pid
|
|
1575
|
-
* ESRCH means the group already exited and the direct-child fallback
|
|
1576
|
-
* takes over), so only a send with no fallback left behind it pairs
|
|
1577
|
-
* with `noteKillFailure`. */
|
|
1578
|
-
const trySend = (send) => {
|
|
1579
|
-
try {
|
|
1580
|
-
send();
|
|
1581
|
-
return true;
|
|
1582
|
-
}
|
|
1583
|
-
catch {
|
|
1584
|
-
return false;
|
|
1585
|
-
}
|
|
1586
|
-
};
|
|
1587
|
-
/** Every signal for this teardown was refused, so the child may still be
|
|
1588
|
-
* running. One record per spawn — the reviewer's reproduction refused
|
|
1589
|
-
* two kills (the fault's and then the timeout's) and a row per attempt
|
|
1590
|
-
* would be a flood. A kind of its own rather than a field on
|
|
1591
|
-
* `spawn-output-cap-truncated`: this fires on the abort and timeout
|
|
1592
|
-
* teardowns too, which have nothing to do with an output cap. */
|
|
1593
|
-
const noteKillFailure = (phase) => {
|
|
1594
|
-
if (killFailed)
|
|
1595
|
-
return;
|
|
1596
|
-
killFailed = true;
|
|
1597
|
-
incrementDegradationCount({
|
|
1598
|
-
kind: "spawn-kill-failed",
|
|
1599
|
-
subject: resourceLabel,
|
|
1600
|
-
reason: `every signal available for the ${phase} teardown was refused; the child may still be running`,
|
|
1601
|
-
metadata: { phase, pid: child.pid },
|
|
1602
|
-
});
|
|
1603
|
-
};
|
|
1604
|
-
// On Windows, shell:true means child.pid is cmd.exe — child.kill() only
|
|
1605
|
-
// kills the wrapper, leaving the actual subprocess (e.g. knip/npx) alive
|
|
1606
|
-
// as an orphan. Use taskkill /F /T to kill the full process tree instead.
|
|
1607
|
-
const killTree = async (phase) => {
|
|
1608
|
-
if (isWindows && child.pid && child.pid > 0) {
|
|
1609
|
-
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
|
|
1610
|
-
try {
|
|
1611
|
-
await new Promise((done) => {
|
|
1612
|
-
const killer = spawn(taskkill, ["/F", "/T", "/PID", String(child.pid)], {
|
|
1613
|
-
shell: false,
|
|
1614
|
-
windowsHide: true,
|
|
1615
|
-
stdio: "ignore",
|
|
1616
|
-
});
|
|
1617
|
-
killer.once("close", () => done());
|
|
1618
|
-
killer.once("error", () => {
|
|
1619
|
-
// E4: a throw HERE would leave the EventEmitter as an
|
|
1620
|
-
// uncaught exception, not a rejection.
|
|
1621
|
-
if (!trySend(() => child.kill("SIGKILL")))
|
|
1622
|
-
noteKillFailure(phase);
|
|
1623
|
-
done();
|
|
1624
|
-
});
|
|
1625
|
-
});
|
|
1626
|
-
}
|
|
1627
|
-
catch {
|
|
1628
|
-
if (!trySend(() => child.kill("SIGKILL")))
|
|
1629
|
-
noteKillFailure(phase);
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
else if (posixProcessGroup && ownsChildPid) {
|
|
1633
|
-
// #2026: signal the whole process group. Grandchildren spawned
|
|
1634
|
-
// by the tool share its group, so one signal reaches the whole
|
|
1635
|
-
// tree; a negative-pid ESRCH means it already exited.
|
|
1636
|
-
const pgid = -child.pid;
|
|
1637
|
-
// #3375 round 3 (M3384-2): through the one sink, like every other
|
|
1638
|
-
// send. A second ad-hoc `try`/`catch` here would be a rival
|
|
1639
|
-
// signal-safety mechanism inside the same teardown — the shape this
|
|
1640
|
-
// change deleted four of — and a later edit to this branch would not
|
|
1641
|
-
// inherit `trySend`'s total send or its failure accounting.
|
|
1642
|
-
// `trySend` returning false here is ORDINARY, not a failure: #2026's
|
|
1643
|
-
// negative-pid ESRCH means the group already exited, so the direct
|
|
1644
|
-
// child below is the normal fallback and nothing is recorded. Only
|
|
1645
|
-
// the last-resort send can fail the teardown.
|
|
1646
|
-
if (!trySend(() => process.kill(pgid, "SIGTERM"))) {
|
|
1647
|
-
if (!trySend(() => child.kill("SIGTERM")))
|
|
1648
|
-
noteKillFailure(phase);
|
|
1649
|
-
}
|
|
1650
|
-
// #2027 round-1: gate the SIGKILL escalation on GROUP liveness,
|
|
1651
|
-
// not direct-child death - a tool can exit instantly on SIGTERM
|
|
1652
|
-
// while a SIGTERM-hardy grandchild keeps the group alive. The
|
|
1653
|
-
// timer stays REF'D for group mode (max 1s tail after resolve):
|
|
1654
|
-
// it is the only backstop for this group once finalize deletes
|
|
1655
|
-
// the pid from lifetimeState.
|
|
1656
|
-
escalationTimer = setTimeout(() => {
|
|
1657
|
-
// The WHOLE body, not just the signal: this runs in a bare
|
|
1658
|
-
// timer callback, where any throw is an uncaught exception.
|
|
1659
|
-
const escalated = trySend(() => {
|
|
1660
|
-
let groupAlive = true;
|
|
1661
|
-
try {
|
|
1662
|
-
process.kill(pgid, 0);
|
|
1663
|
-
}
|
|
1664
|
-
catch {
|
|
1665
|
-
groupAlive = false;
|
|
1666
|
-
}
|
|
1667
|
-
if (!groupAlive)
|
|
1668
|
-
return;
|
|
1669
|
-
teardownEscalated = true;
|
|
1670
|
-
logLatency({
|
|
1671
|
-
type: "phase",
|
|
1672
|
-
phase: "spawn_group_kill_escalation",
|
|
1673
|
-
filePath: "",
|
|
1674
|
-
durationMs: 0,
|
|
1675
|
-
metadata: { pgid: child.pid },
|
|
1676
|
-
});
|
|
1677
|
-
// Through the sink too (#3375 r3 / M3384-2): one vocabulary
|
|
1678
|
-
// for every send in this function. A false here is ORDINARY
|
|
1679
|
-
// — the group raced its own exit — so it is deliberately not
|
|
1680
|
-
// escalated to `noteKillFailure`; the enclosing `trySend`
|
|
1681
|
-
// already guarantees nothing escapes this timer callback.
|
|
1682
|
-
trySend(() => process.kill(pgid, "SIGKILL"));
|
|
1683
|
-
});
|
|
1684
|
-
if (!escalated)
|
|
1685
|
-
noteKillFailure(phase);
|
|
1686
|
-
}, 1000);
|
|
1687
|
-
}
|
|
1688
|
-
else {
|
|
1689
|
-
// E2/E3. This branch runs whenever `/proc` cannot prove pid
|
|
1690
|
-
// ownership — every macOS host, and any Linux host with an
|
|
1691
|
-
// unreadable `/proc` — so it is not a hypothetical path.
|
|
1692
|
-
if (!trySend(() => child.kill("SIGTERM")))
|
|
1693
|
-
noteKillFailure(phase);
|
|
1694
|
-
escalationTimer = setTimeout(() => {
|
|
1695
|
-
if (closed)
|
|
1696
|
-
return;
|
|
1697
|
-
teardownEscalated = true;
|
|
1698
|
-
if (!trySend(() => child.kill("SIGKILL")))
|
|
1699
|
-
noteKillFailure(phase);
|
|
1700
|
-
}, 1000);
|
|
1701
|
-
}
|
|
1702
|
-
};
|
|
1703
|
-
// Handle abort signal
|
|
1704
|
-
const onAbort = () => {
|
|
1705
|
-
aborted = true;
|
|
1706
|
-
if (!killed && !child.killed) {
|
|
1707
|
-
killed = true;
|
|
1708
|
-
void killTree("abort");
|
|
1709
|
-
}
|
|
1710
|
-
};
|
|
1711
|
-
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
1712
|
-
// Output-cap kills are awaited by finalize() below, just like
|
|
1713
|
-
// timeout/abort kills. An armed, unmatched streaming matcher is the one
|
|
1714
|
-
// exception: retention remains bounded, but the caller's timeout is the
|
|
1715
|
-
// bound while the child gets a chance to emit its rescue line.
|
|
1716
|
-
let killPromise;
|
|
1717
|
-
const stopForOutputLimit = () => {
|
|
1718
|
-
const waitingForStreamingMatch = streamingMatcher !== undefined && !streamingMatch;
|
|
1719
|
-
if (outputTruncated &&
|
|
1720
|
-
!waitingForStreamingMatch &&
|
|
1721
|
-
!killed &&
|
|
1722
|
-
!child.killed) {
|
|
1723
|
-
killedForOutputCap = true;
|
|
1724
|
-
killed = true;
|
|
1725
|
-
killPromise = killTree("output-cap");
|
|
1726
|
-
}
|
|
1727
|
-
};
|
|
1728
|
-
// #3375: one bounded, counted ledger row per command label the moment the
|
|
1729
|
-
// cap first bites - the crash report named no command, no byte count and
|
|
1730
|
-
// no cap value, so nothing in it could identify the chatty producer.
|
|
1731
|
-
// `incrementDegradationCount` keeps one latest-reason entry per subject
|
|
1732
|
-
// with an exact tally and writes durable rows only at powers of two, so a
|
|
1733
|
-
// tool that trips the cap on every dispatch cannot flood the sink.
|
|
1734
|
-
const recordOutputCapTrip = () => {
|
|
1735
|
-
incrementDegradationCount({
|
|
1736
|
-
kind: "spawn-output-cap-truncated",
|
|
1737
|
-
subject: resourceLabel,
|
|
1738
|
-
reason: `retained output reached the ${maxOutputBytes}-byte ${capFromCaller ? "caller" : "default"} cap after ${observedOutputBytes} bytes; child ${killedForOutputCap ? "terminated" : "left running"}`,
|
|
1739
|
-
metadata: {
|
|
1740
|
-
capBytes: maxOutputBytes,
|
|
1741
|
-
capSource: capFromCaller ? "caller" : "default",
|
|
1742
|
-
observedBytes: observedOutputBytes,
|
|
1743
|
-
killed: killedForOutputCap,
|
|
1744
|
-
},
|
|
1745
|
-
});
|
|
1746
|
-
};
|
|
1747
|
-
// #3375: the fault path for a chunk handler that threw. Every statement
|
|
1748
|
-
// here is total by construction - plain assignments, a ledger call that
|
|
1749
|
-
// swallows its own failures, and `killTree()`, which is `async` so a
|
|
1750
|
-
// synchronous fault inside it becomes a rejected promise rather than a
|
|
1751
|
-
// throw - because a throw raised HERE would escape the same way the
|
|
1752
|
-
// original RangeError did.
|
|
1753
|
-
const failOutputHandler = (stream, error) => {
|
|
1754
|
-
outputHandlerFailed = true;
|
|
1755
|
-
// Retention is over. The bytes already in `stdout`/`stderr` stay as
|
|
1756
|
-
// the bounded output: nothing renders from the retained head/tail
|
|
1757
|
-
// again, because the re-entry guard returns before that can happen.
|
|
1758
|
-
// That is also why `truncationStream` is deliberately NOT set here —
|
|
1759
|
-
// only `renderOutput` reads it, and nothing renders after a fault, so
|
|
1760
|
-
// the assignment was mutation-inert (removing it reds nothing).
|
|
1761
|
-
outputTruncated = true;
|
|
1762
|
-
incrementDegradationCount({
|
|
1763
|
-
kind: "spawn-output-handler-fault",
|
|
1764
|
-
subject: resourceLabel,
|
|
1765
|
-
reason: `${stream} chunk handling threw ${error.name} after ${observedOutputBytes} bytes; retention stopped and the child was terminated`,
|
|
1766
|
-
metadata: {
|
|
1767
|
-
stream,
|
|
1768
|
-
error: error.name,
|
|
1769
|
-
capBytes: maxOutputBytes,
|
|
1770
|
-
observedBytes: observedOutputBytes,
|
|
1771
|
-
},
|
|
1772
|
-
});
|
|
1773
|
-
if (!killed && !child.killed) {
|
|
1774
|
-
killedForOutputCap = true;
|
|
1775
|
-
killed = true;
|
|
1776
|
-
killPromise = killTree("handler-fault");
|
|
1777
|
-
}
|
|
1778
|
-
};
|
|
1779
|
-
// Collect output
|
|
1780
|
-
child.stdout?.setEncoding("utf-8");
|
|
1781
|
-
child.stderr?.setEncoding("utf-8");
|
|
1782
|
-
// #3375: a throw raised inside a stream `data` handler is delivered to
|
|
1783
|
-
// the process, not to the caller awaiting this spawn - no `try`/`catch`
|
|
1784
|
-
// around `await safeSpawnAsync(...)` can contain it, which is why a
|
|
1785
|
-
// `RangeError: Invalid string length` from `appendOutput` terminated the
|
|
1786
|
-
// Pi host on 2026-09-22. Every statement the handler runs lives inside
|
|
1787
|
-
// this `try`, so ANY fault in the retention path (not just that one
|
|
1788
|
-
// RangeError) becomes the bounded output-cap result the caller already
|
|
1789
|
-
// knows how to read.
|
|
1790
|
-
const onOutputChunk = (stream, data) => {
|
|
1791
|
-
if (outputHandlerFailed)
|
|
1792
|
-
return;
|
|
1793
|
-
try {
|
|
1794
|
-
observedOutputBytes += Buffer.byteLength(data);
|
|
1795
|
-
const wasTruncated = outputTruncated;
|
|
1796
|
-
matchStreamingChunk(stream, data);
|
|
1797
|
-
if (stream === "stdout")
|
|
1798
|
-
stdout = appendOutput(stream, stdout, data);
|
|
1799
|
-
else
|
|
1800
|
-
stderr = appendOutput(stream, stderr, data);
|
|
1801
|
-
if (outputTruncated)
|
|
1802
|
-
refreshRetainedOutputs();
|
|
1803
|
-
stopForOutputLimit();
|
|
1804
|
-
if (!wasTruncated && outputTruncated)
|
|
1805
|
-
recordOutputCapTrip();
|
|
1806
|
-
rearmIdleGrace?.();
|
|
1807
|
-
}
|
|
1808
|
-
catch (error) {
|
|
1809
|
-
failOutputHandler(stream, toError(error));
|
|
1810
|
-
}
|
|
1811
|
-
};
|
|
1812
|
-
child.stdout?.on("data", (data) => onOutputChunk("stdout", data));
|
|
1813
|
-
child.stderr?.on("data", (data) => onOutputChunk("stderr", data));
|
|
1814
|
-
// #1673 review round 3 (F2): registered HERE, at spawn time, alongside
|
|
1815
|
-
// the other handlers above — not inside `waitForPipeIdle`. Real Node
|
|
1816
|
-
// emits `close` a fraction of a millisecond after `exit` (both fire in
|
|
1817
|
-
// the same "process exit" turn, well before any microtask), so a
|
|
1818
|
-
// listener attached inside `waitForPipeIdle` — which `finalize` only
|
|
1819
|
-
// reaches after `await killPromise`, at least one microtask later — is
|
|
1820
|
-
// always attaching to an event that has already happened. That's why
|
|
1821
|
-
// the round-2 `child.on("close", finish)` placed inside
|
|
1822
|
-
// `waitForPipeIdle` never actually caught a real spawn's `close` early:
|
|
1823
|
-
// every real call still paid the full grace window. Capturing the
|
|
1824
|
-
// boolean here, before any await, means `waitForPipeIdle` can check
|
|
1825
|
-
// "did close already happen" synchronously the moment it runs.
|
|
1826
|
-
let closeSeen = false;
|
|
1827
|
-
// #2010: WHEN death was observed - the stop-clock for teardown evidence
|
|
1828
|
-
// and the discriminator for its outcome. A close that predates the
|
|
1829
|
-
// budget-expired kill means the child exited on its own ("exited"), no
|
|
1830
|
-
// matter that the timer callback raced and armed a kill anyway.
|
|
1831
|
-
let closedAtMs;
|
|
1832
|
-
child.on("close", () => {
|
|
1833
|
-
closeSeen = true;
|
|
1834
|
-
closedAtMs ??= Date.now();
|
|
1835
|
-
});
|
|
1836
|
-
// Timeout handling - KILL the process, don't just abandon it
|
|
1837
|
-
const timeoutId = setTimeout(() => {
|
|
1838
|
-
timedOut = true;
|
|
1839
|
-
if (!killed && !child.killed) {
|
|
1840
|
-
killed = true;
|
|
1841
|
-
teardownStartedAtMs = Date.now();
|
|
1842
|
-
killPromise = killTree("timeout");
|
|
1843
|
-
}
|
|
1844
|
-
}, timeout);
|
|
1845
|
-
// #620: stop the poll and log peak/average CPU%+RSS for this invocation
|
|
1846
|
-
// into the existing per-runner latency.log phase entries — best-effort,
|
|
1847
|
-
// wrapped so a logging hiccup can never affect the resolved SpawnResult.
|
|
1848
|
-
const finishResourceUsage = () => {
|
|
1849
|
-
const summary = usageSampler.stop();
|
|
1850
|
-
if (!summary)
|
|
1851
|
-
return undefined;
|
|
1852
|
-
try {
|
|
1853
|
-
logLatency({
|
|
1854
|
-
type: "phase",
|
|
1855
|
-
phase: "spawn_resource_usage",
|
|
1856
|
-
filePath: "",
|
|
1857
|
-
durationMs: 0,
|
|
1858
|
-
metadata: {
|
|
1859
|
-
command: resourceLabel,
|
|
1860
|
-
...summary,
|
|
1861
|
-
},
|
|
1862
|
-
});
|
|
1863
|
-
}
|
|
1864
|
-
catch {
|
|
1865
|
-
// best-effort logging only
|
|
1866
|
-
}
|
|
1867
|
-
return summary;
|
|
1868
|
-
};
|
|
1869
|
-
// #1656: wait for stdout/stderr to fall idle after exit instead of
|
|
1870
|
-
// waiting for Node's "close" event (see the constants' doc comment
|
|
1871
|
-
// above). Resolves immediately if the child has no pipes to drain.
|
|
1872
|
-
//
|
|
1873
|
-
// These timers stay REF'D, matching `timeoutId`/`escalationTimer`
|
|
1874
|
-
// elsewhere in this function: the caller's own settlement depends on
|
|
1875
|
-
// them firing, so unref'ing would let a short-lived host process (a
|
|
1876
|
-
// one-shot CLI whose only other in-flight work was this spawn) exit
|
|
1877
|
-
// the event loop before `finish` ever runs — silently abandoning this
|
|
1878
|
-
// promise instead of merely leaking a handle. Both paths to `finish`
|
|
1879
|
-
// clear both timers, so nothing outlives settlement either way.
|
|
1880
|
-
const waitForPipeIdle = () => {
|
|
1881
|
-
if (!child.stdout && !child.stderr)
|
|
1882
|
-
return Promise.resolve();
|
|
1883
|
-
// #1673 review round 3 (F2): `close` fires a fraction of a
|
|
1884
|
-
// millisecond after `exit` for a real Node child — well before
|
|
1885
|
-
// `finalize`'s `await killPromise` ever yields, i.e. before this
|
|
1886
|
-
// function is even called. By the time we get here, `closeSeen`
|
|
1887
|
-
// (set by the listener registered at spawn time, above) already
|
|
1888
|
-
// reflects reality for the overwhelming majority of real spawns:
|
|
1889
|
-
// nothing is holding this child's stdio open, so don't pay out the
|
|
1890
|
-
// 100ms grace window for a signal that already arrived.
|
|
1891
|
-
if (closeSeen)
|
|
1892
|
-
return Promise.resolve();
|
|
1893
|
-
return new Promise((finishIdleWait) => {
|
|
1894
|
-
let settled = false;
|
|
1895
|
-
let graceTimer;
|
|
1896
|
-
const capTimer = setTimeout(() => {
|
|
1897
|
-
finish();
|
|
1898
|
-
}, EXIT_PIPE_IDLE_MAX_WAIT_MS);
|
|
1899
|
-
const finish = () => {
|
|
1900
|
-
if (settled)
|
|
1901
|
-
return;
|
|
1902
|
-
settled = true;
|
|
1903
|
-
rearmIdleGrace = undefined;
|
|
1904
|
-
if (graceTimer)
|
|
1905
|
-
clearTimeout(graceTimer);
|
|
1906
|
-
clearTimeout(capTimer);
|
|
1907
|
-
// Optional chaining, not a bare call: `child` is a real Node
|
|
1908
|
-
// ChildProcess (always an EventEmitter, always has this) in
|
|
1909
|
-
// production, but several existing test doubles across the
|
|
1910
|
-
// suite fake stdio-bearing children as plain objects with only
|
|
1911
|
-
// the handful of methods (`on`, `kill`, ...) their scenario
|
|
1912
|
-
// needs — no `removeListener`. Skipping cleanup on those is
|
|
1913
|
-
// harmless (the fake is torn down with the test either way);
|
|
1914
|
-
// crashing here from inside a timer callback is not: it would
|
|
1915
|
-
// throw before `finishIdleWait()` runs, hanging every caller's
|
|
1916
|
-
// `safeSpawnAsync` promise (#1673 review round 2 caught this
|
|
1917
|
-
// live in tests/clients/installer/version-drift.test.ts).
|
|
1918
|
-
child.removeListener?.("close", finish);
|
|
1919
|
-
finishIdleWait();
|
|
1920
|
-
};
|
|
1921
|
-
// The `closeSeen` fast-path above catches the common case (close
|
|
1922
|
-
// already happened by the time we get here). This listener is
|
|
1923
|
-
// the fallback for the rare case where `close` genuinely lands
|
|
1924
|
-
// DURING the wait — e.g. a child whose stdio takes a beat
|
|
1925
|
-
// longer than usual to release, but still well under the grace
|
|
1926
|
-
// window. `close` is a stronger signal than "no data for
|
|
1927
|
-
// EXIT_PIPE_IDLE_GRACE_MS", so finishing on it here still beats
|
|
1928
|
-
// waiting out the timer. The anti-hang property is untouched
|
|
1929
|
-
// either way: a daemonized descendant holding the pipe open is
|
|
1930
|
-
// EXACTLY the case where `close` never fires at all, so the
|
|
1931
|
-
// grace/cap timers still bound that case as before.
|
|
1932
|
-
child.on("close", finish);
|
|
1933
|
-
rearmIdleGrace = () => {
|
|
1934
|
-
if (graceTimer)
|
|
1935
|
-
clearTimeout(graceTimer);
|
|
1936
|
-
graceTimer = setTimeout(finish, EXIT_PIPE_IDLE_GRACE_MS);
|
|
1937
|
-
};
|
|
1938
|
-
rearmIdleGrace();
|
|
1939
|
-
});
|
|
1940
|
-
};
|
|
1941
|
-
// Process completion
|
|
1942
|
-
//
|
|
1943
|
-
// #1656 x #1651/#1670: finalize off BOTH `exit` and `close`, not just
|
|
1944
|
-
// one — each covers a shape the other misses.
|
|
1945
|
-
// - `exit` is what unblocks the #1656 hang: a daemonized descendant
|
|
1946
|
-
// that inherited our stdout/stderr pipe can hold that fd open
|
|
1947
|
-
// forever, so `close` (which waits for every fd referencing the
|
|
1948
|
-
// child to release) never fires even though the child we spawned
|
|
1949
|
-
// is long dead. `exit` always fires once the child itself exits,
|
|
1950
|
-
// independent of who still holds its pipes.
|
|
1951
|
-
// - `close` is still wired because a child that never actually
|
|
1952
|
-
// launched (bad binary, EACCES, ...) gets NO `exit` event at all —
|
|
1953
|
-
// Node only emits `error` then `close(code<0, null)` for that
|
|
1954
|
-
// shape (#1651/#1670's shape guard below decides from exactly
|
|
1955
|
-
// this). `exit`-only would make that guard unreachable.
|
|
1956
|
-
// Whichever fires first drives `finalize`; `settled` (distinct from
|
|
1957
|
-
// `resolved`, which `finalize` itself owns) stops the second event
|
|
1958
|
-
// from dispatching a redundant, concurrently-racing `finalize` call —
|
|
1959
|
-
// double-invoking it would double-run `waitForPipeIdle` and
|
|
1960
|
-
// `finishResourceUsage` against the same child.
|
|
1961
|
-
let settled = false;
|
|
1962
|
-
const onChildSettle = (code, signal) => {
|
|
1963
|
-
if (settled || resolved)
|
|
1964
|
-
return;
|
|
1965
|
-
settled = true;
|
|
1966
|
-
closed = true;
|
|
1967
|
-
clearTimeout(timeoutId);
|
|
1968
|
-
abortSignal?.removeEventListener("abort", onAbort);
|
|
1969
|
-
if (child.pid)
|
|
1970
|
-
lifetimeState.pids.delete(child.pid);
|
|
1971
|
-
void finalize(code, signal);
|
|
1972
|
-
};
|
|
1973
|
-
const finalize = async (code, signal) => {
|
|
1974
|
-
// Belt, not the decision: whichever path settles `resolved` FIRST
|
|
1975
|
-
// wins. A healthy run's outcome must never be discarded by a late,
|
|
1976
|
-
// unrelated 'error' (e.g. a post-exit kill() that itself failed
|
|
1977
|
-
// with EPERM, #1651 review F4) — so this only bails when something
|
|
1978
|
-
// else has ALREADY resolved, never on `error` merely having fired.
|
|
1979
|
-
await killPromise;
|
|
1980
|
-
if (resolved)
|
|
1981
|
-
return;
|
|
1982
|
-
// #1109: the child has exited - clear the non-Windows escalation
|
|
1983
|
-
// timer so it doesn't linger as a ref'd handle. #2027 EXCEPTION:
|
|
1984
|
-
// in POSIX group mode the timer is the liveness-gated SIGKILL
|
|
1985
|
-
// backstop for SIGTERM-hardy grandchildren; it stays armed and
|
|
1986
|
-
// self-cleans within 1s of firing (max 1s ref'd tail).
|
|
1987
|
-
if (escalationTimer && !posixProcessGroup) {
|
|
1988
|
-
clearTimeout(escalationTimer);
|
|
1989
|
-
}
|
|
1990
|
-
// #1673 review F1: latch the verdict as DECIDED here, before the
|
|
1991
|
-
// idle-pipe wait — not after it. `code`/`signal`/`timedOut`/`aborted`
|
|
1992
|
-
// are already fixed by this point (they don't change during the
|
|
1993
|
-
// wait), so nothing below depends on staying unresolved across it.
|
|
1994
|
-
// Leaving `resolved` false across the wait would reopen the exact
|
|
1995
|
-
// race #1651 review F4 closed: a late, unrelated 'error' (e.g. a
|
|
1996
|
-
// post-exit kill() failing with EPERM) landing inside the
|
|
1997
|
-
// idle-grace window (bounded, but up to EXIT_PIPE_IDLE_MAX_WAIT_MS)
|
|
1998
|
-
// would steal an already-decided healthy verdict. Pre-#1656 that
|
|
1999
|
-
// race window was one microtask — the gap between `close` firing
|
|
2000
|
-
// and `resolve()` being called synchronously in the same handler.
|
|
2001
|
-
// This closes the window back down to that same shape instead of
|
|
2002
|
-
// widening it to the whole wait.
|
|
2003
|
-
resolved = true;
|
|
2004
|
-
await waitForPipeIdle();
|
|
2005
|
-
const resourceUsage = finishResourceUsage();
|
|
2006
|
-
const outputInfo = {
|
|
2007
|
-
...(outputTruncated ? { outputTruncated: true } : {}),
|
|
2008
|
-
...(killedForOutputCap ? { killedForOutputCap: true } : {}),
|
|
2009
|
-
...(killFailed ? { killFailed: true } : {}),
|
|
2010
|
-
};
|
|
2011
|
-
const streamingMatchInfo = streamingMatch ? { streamingMatch: true } : {};
|
|
2012
|
-
// #1816: surface the signal name as a field on every path where one
|
|
2013
|
-
// exists, so callers can NAME it instead of scraping `error.message`.
|
|
2014
|
-
const signalInfo = signal ? { signal } : {};
|
|
2015
|
-
if (timedOut) {
|
|
2016
|
-
const cause = new Error(`Process timed out after ${timeout}ms (killed with ${signal || "SIGTERM"})`);
|
|
2017
|
-
// #2010: teardown evidence lives ONLY on the timeout path - it
|
|
2018
|
-
// describes how long the tree took to die after the budget
|
|
2019
|
-
// expired, and how it died ("exited" = the child beat the signal;
|
|
2020
|
-
// "killed-by-signal" / "escalate-kill" = how WE ended it).
|
|
2021
|
-
const timeoutTeardown = resolveTeardownOutcome({
|
|
2022
|
-
killStartedAtMs: teardownStartedAtMs,
|
|
2023
|
-
deathObservedAtMs: closedAtMs,
|
|
2024
|
-
escalated: teardownEscalated,
|
|
2025
|
-
});
|
|
2026
|
-
resolve({
|
|
2027
|
-
stdout,
|
|
2028
|
-
stderr,
|
|
2029
|
-
status: null,
|
|
2030
|
-
error: cause,
|
|
2031
|
-
failure: "timeout",
|
|
2032
|
-
...signalInfo,
|
|
2033
|
-
...(timeoutTeardown && { timeoutTeardown }),
|
|
2034
|
-
spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
|
|
2035
|
-
...outputInfo,
|
|
2036
|
-
...streamingMatchInfo,
|
|
2037
|
-
resourceUsage,
|
|
2038
|
-
});
|
|
2039
|
-
}
|
|
2040
|
-
else if (aborted) {
|
|
2041
|
-
const cause = new Error("Spawn aborted");
|
|
2042
|
-
resolve({
|
|
2043
|
-
stdout,
|
|
2044
|
-
stderr,
|
|
2045
|
-
status: null,
|
|
2046
|
-
error: cause,
|
|
2047
|
-
failure: "aborted",
|
|
2048
|
-
...signalInfo,
|
|
2049
|
-
spawnFailure: new SpawnFailureError("killed", cause.message, cause),
|
|
2050
|
-
...outputInfo,
|
|
2051
|
-
...streamingMatchInfo,
|
|
2052
|
-
resourceUsage,
|
|
2053
|
-
});
|
|
2054
|
-
}
|
|
2055
|
-
else if (signal) {
|
|
2056
|
-
const cause = new Error(`Process killed by signal: ${signal}`);
|
|
2057
|
-
resolve({
|
|
2058
|
-
stdout,
|
|
2059
|
-
stderr,
|
|
2060
|
-
status: null,
|
|
2061
|
-
error: cause,
|
|
2062
|
-
failure: "signal",
|
|
2063
|
-
...signalInfo,
|
|
2064
|
-
spawnFailure: new SpawnFailureError("killed", cause.message, cause),
|
|
2065
|
-
...outputInfo,
|
|
2066
|
-
...streamingMatchInfo,
|
|
2067
|
-
resourceUsage,
|
|
2068
|
-
});
|
|
2069
|
-
}
|
|
2070
|
-
else if (code === null || code < 0) {
|
|
2071
|
-
// #1651 review F3: a real completed process never exits with a
|
|
2072
|
-
// null or negative code (Node reports the negated libuv/errno for
|
|
2073
|
-
// a child that never launched — e.g. ENOENT: -2 on Linux, -4058
|
|
2074
|
-
// on Windows). This decides from that SHAPE alone, independent of
|
|
2075
|
-
// whether/when 'error' has fired — Node's docs leave the
|
|
2076
|
-
// 'error'/'close' ordering for a failed spawn unspecified, and a
|
|
2077
|
-
// same-microtask re-check (the #1651 first-round fix) still loses
|
|
2078
|
-
// if 'error' lands a tick later via setImmediate/nextTick.
|
|
2079
|
-
// Reuse `pendingSpawnError` when 'error' already landed (a more
|
|
2080
|
-
// informative Error — ENOENT vs EACCES vs other); otherwise
|
|
2081
|
-
// synthesize an ENOENT-shaped one so a genuinely missing tool is
|
|
2082
|
-
// never read as a clean, answered run.
|
|
2083
|
-
const cause = pendingSpawnError ?? synthesizeEnoentError(command);
|
|
2084
|
-
const spawnFailure = await classifySpawnFailure(cause, {
|
|
2085
|
-
command,
|
|
2086
|
-
cwd: options?.cwd,
|
|
2087
|
-
});
|
|
2088
|
-
resolve({
|
|
2089
|
-
stdout,
|
|
2090
|
-
stderr,
|
|
2091
|
-
status: code,
|
|
2092
|
-
error: cause,
|
|
2093
|
-
failure: "spawn",
|
|
2094
|
-
spawnFailure,
|
|
2095
|
-
...outputInfo,
|
|
2096
|
-
...streamingMatchInfo,
|
|
2097
|
-
resourceUsage,
|
|
2098
|
-
});
|
|
2099
|
-
}
|
|
2100
|
-
else {
|
|
2101
|
-
resolve({
|
|
2102
|
-
stdout,
|
|
2103
|
-
stderr,
|
|
2104
|
-
status: code,
|
|
2105
|
-
...outputInfo,
|
|
2106
|
-
...streamingMatchInfo,
|
|
2107
|
-
resourceUsage,
|
|
2108
|
-
});
|
|
2109
|
-
}
|
|
2110
|
-
};
|
|
2111
|
-
child.on("exit", onChildSettle);
|
|
2112
|
-
child.on("close", onChildSettle);
|
|
2113
|
-
child.on("error", (err) => {
|
|
2114
|
-
// Capture unconditionally — even when `close` already resolved, this
|
|
2115
|
-
// keeps the most recent Error available for anyone reading state
|
|
2116
|
-
// mid-flight, and costs nothing (the assignment, not a resolve).
|
|
2117
|
-
pendingSpawnError = err;
|
|
2118
|
-
// A completed run's own verdict must never be downgraded by an
|
|
2119
|
-
// unrelated LATE error (#1651 review F4 — e.g. a post-exit kill()
|
|
2120
|
-
// failing with EPERM after `close(0, null)` already answered).
|
|
2121
|
-
if (resolved)
|
|
2122
|
-
return;
|
|
2123
|
-
resolved = true;
|
|
2124
|
-
closed = true;
|
|
2125
|
-
clearTimeout(timeoutId);
|
|
2126
|
-
abortSignal?.removeEventListener("abort", onAbort);
|
|
2127
|
-
// #2027: group-mode timer stays armed as the grandchild backstop.
|
|
2128
|
-
if (escalationTimer && !posixProcessGroup) {
|
|
2129
|
-
clearTimeout(escalationTimer);
|
|
2130
|
-
}
|
|
2131
|
-
if (child.pid)
|
|
2132
|
-
lifetimeState.pids.delete(child.pid);
|
|
2133
|
-
const resourceUsage = finishResourceUsage();
|
|
2134
|
-
let failure = "spawn";
|
|
2135
|
-
if (aborted)
|
|
2136
|
-
failure = "aborted";
|
|
2137
|
-
else if (timedOut)
|
|
2138
|
-
failure = "timeout";
|
|
2139
|
-
const controlFailure = aborted
|
|
2140
|
-
? new SpawnFailureError("killed", err.message, err)
|
|
2141
|
-
: timedOut
|
|
2142
|
-
? new SpawnFailureError("timeout", err.message, err)
|
|
2143
|
-
: undefined;
|
|
2144
|
-
const finish = (spawnFailure) => resolve({
|
|
2145
|
-
stdout,
|
|
2146
|
-
stderr,
|
|
2147
|
-
status: null,
|
|
2148
|
-
error: err,
|
|
2149
|
-
failure,
|
|
2150
|
-
spawnFailure,
|
|
2151
|
-
...(outputTruncated ? { outputTruncated: true } : {}),
|
|
2152
|
-
...(killedForOutputCap ? { killedForOutputCap: true } : {}),
|
|
2153
|
-
...(killFailed ? { killFailed: true } : {}),
|
|
2154
|
-
...(streamingMatch ? { streamingMatch: true } : {}),
|
|
2155
|
-
resourceUsage,
|
|
2156
|
-
});
|
|
2157
|
-
if (controlFailure)
|
|
2158
|
-
finish(controlFailure);
|
|
2159
|
-
else {
|
|
2160
|
-
void classifySpawnFailure(err, { command, cwd: options?.cwd }).then(finish);
|
|
2161
|
-
}
|
|
2162
|
-
});
|
|
2163
|
-
});
|
|
2164
|
-
}
|
|
2165
|
-
/**
|
|
2166
|
-
* Run multiple commands concurrently with limited concurrency.
|
|
2167
|
-
*
|
|
2168
|
-
* This prevents resource contention when running many linters.
|
|
2169
|
-
* Uses async spawn with concurrency limiting built-in.
|
|
2170
|
-
*
|
|
2171
|
-
* @example
|
|
2172
|
-
* const results = await safeSpawnBatch([
|
|
2173
|
-
* { command: "biome", args: ["check", "file.ts"] },
|
|
2174
|
-
* { command: "ruff", args: ["check", "file.py"] },
|
|
2175
|
-
* ], 3); // Max 3 concurrent
|
|
2176
|
-
*/
|
|
2177
|
-
export async function safeSpawnBatch(commands, concurrency = 3) {
|
|
2178
|
-
const results = [];
|
|
2179
|
-
// Process in batches to limit concurrent processes
|
|
2180
|
-
for (let i = 0; i < commands.length; i += concurrency) {
|
|
2181
|
-
const batch = commands.slice(i, i + concurrency);
|
|
2182
|
-
const batchResults = await Promise.all(batch.map(({ command, args, options }) => safeSpawnAsync(command, args, options)));
|
|
2183
|
-
results.push(...batchResults);
|
|
2184
|
-
}
|
|
2185
|
-
return results;
|
|
2186
|
-
}
|
|
2187
|
-
/**
|
|
2188
|
-
* Check if a command is available in PATH (async version)
|
|
2189
|
-
*/
|
|
2190
|
-
export async function isCommandAvailableAsync(command) {
|
|
2191
|
-
const finder = process.platform === "win32" ? "where" : "which";
|
|
2192
|
-
const result = await safeSpawnAsync(finder, [command], { timeout: 5000 });
|
|
2193
|
-
return result.status === 0 && !result.error;
|
|
2194
|
-
}
|
|
2195
|
-
/**
|
|
2196
|
-
* Find the full path to a command (async version)
|
|
2197
|
-
*/
|
|
2198
|
-
export async function findCommandAsync(command) {
|
|
2199
|
-
const finder = process.platform === "win32" ? "where" : "which";
|
|
2200
|
-
const result = await safeSpawnAsync(finder, [command], { timeout: 5000 });
|
|
2201
|
-
if (result.status !== 0 || result.error)
|
|
2202
|
-
return null;
|
|
2203
|
-
// Take first line (first match)
|
|
2204
|
-
return result.stdout.trim().split("\n")[0] || null;
|
|
2205
|
-
}
|
|
2206
|
-
// ============================================================================
|
|
2207
|
-
// SYNC VERSION (Deprecated - Blocking, for backward compatibility)
|
|
2208
|
-
// ============================================================================
|
|
2209
|
-
/**
|
|
2210
|
-
* ⚠️ DEPRECATED: Use safeSpawnAsync instead.
|
|
2211
|
-
*
|
|
2212
|
-
* This blocks the entire Node.js event loop until the process exits.
|
|
2213
|
-
* If the process hangs, pi will freeze.
|
|
2214
|
-
*
|
|
2215
|
-
* Kept for backward compatibility during migration (today's only caller:
|
|
2216
|
-
* `test-runner-client.ts`'s synchronous pytest-on-PATH probe, called from a
|
|
2217
|
-
* sync detection path with many sync callers/tests — not a trivial async
|
|
2218
|
-
* migration, see #817 follow-up discussion).
|
|
2219
|
-
*
|
|
2220
|
-
* #817: the Windows branch used to build a `cmd.exe`/`shell:true` command
|
|
2221
|
-
* line from unvalidated caller input (CodeQL #17/#18/#19), same unsoundness
|
|
2222
|
-
* as the async version had. It now shares the exact same resolution/
|
|
2223
|
-
* validation seams as `safeSpawnAsync` — `resolveWindowsCommand` (cached
|
|
2224
|
-
* PATH+PATHEXT walk), direct `spawnSync(resolvedPath, args, { shell: false })`
|
|
2225
|
-
* for `.exe`/`.com`, the pinned-cmd.exe wrapper + `findCmdUnsafeValue`
|
|
2226
|
-
* rejection for `.cmd`/`.bat`, and a synthesized ENOENT when unresolvable.
|
|
2227
|
-
* No `shell: true` anywhere.
|
|
2228
|
-
*/
|
|
2229
|
-
export function safeSpawn(command, args, options) {
|
|
2230
|
-
const spawnEnv = getSpawnEnvironment(options?.env);
|
|
2231
|
-
if (process.platform === "win32") {
|
|
2232
|
-
// See the matching comment in safeSpawnAsync: the child's cwd doesn't
|
|
2233
|
-
// need OUR validation (Windows resolves it natively), so an unprovable
|
|
2234
|
-
// drive-relative cwd falls back to the raw value instead of failing the
|
|
2235
|
-
// whole spawn before ever attempting it (#1201). Command resolution
|
|
2236
|
-
// below intentionally still uses the ORIGINAL raw cwd and fails closed
|
|
2237
|
-
// for PATH entries that would need it to be canonical.
|
|
2238
|
-
// See safeSpawnAsync for why there is no `?? process.cwd()` third arm.
|
|
2239
|
-
const spawnCwd = resolveEffectiveWindowsCwd(options?.cwd, spawnEnv) ?? options?.cwd;
|
|
2240
|
-
const resolved = resolveWindowsCommand(command, options?.cwd, spawnEnv);
|
|
2241
|
-
if (!resolved) {
|
|
2242
|
-
const error = synthesizeEnoentError(command);
|
|
2243
|
-
return {
|
|
2244
|
-
stdout: "",
|
|
2245
|
-
stderr: "",
|
|
2246
|
-
status: null,
|
|
2247
|
-
error,
|
|
2248
|
-
failure: "spawn",
|
|
2249
|
-
spawnFailure: classifySpawnFailureSync(error, {
|
|
2250
|
-
command,
|
|
2251
|
-
cwd: options?.cwd,
|
|
2252
|
-
}),
|
|
2253
|
-
};
|
|
2254
|
-
}
|
|
2255
|
-
let spawnCmd;
|
|
2256
|
-
let spawnArgs;
|
|
2257
|
-
let windowsVerbatimArguments = false;
|
|
2258
|
-
if (resolved.ext === ".cmd" || resolved.ext === ".bat") {
|
|
2259
|
-
// Validate the value that actually gets interpolated into the /c
|
|
2260
|
-
// line — the RESOLVED path, not the caller's original (possibly
|
|
2261
|
-
// extensionless) `command` string — plus every arg.
|
|
2262
|
-
const unsafeValue = findCmdUnsafeValue(resolved.resolvedPath, args);
|
|
2263
|
-
if (unsafeValue !== undefined) {
|
|
2264
|
-
const error = new Error(`Refusing to spawn "${resolved.resolvedPath}" via cmd.exe: ` +
|
|
2265
|
-
`${JSON.stringify(unsafeValue)} contains a character ("` +
|
|
2266
|
-
`, %, !, or CR/LF) that cannot be safely escaped on a ` +
|
|
2267
|
-
`cmd.exe /c command line (CWE-78, #817). Rename/quote the ` +
|
|
2268
|
-
"value or invoke the tool without going through cmd.exe.");
|
|
2269
|
-
return {
|
|
2270
|
-
stdout: "",
|
|
2271
|
-
stderr: "",
|
|
2272
|
-
status: null,
|
|
2273
|
-
error,
|
|
2274
|
-
failure: "spawn",
|
|
2275
|
-
spawnFailure: classifySpawnFailureSync(error, {
|
|
2276
|
-
command,
|
|
2277
|
-
cwd: options?.cwd,
|
|
2278
|
-
}),
|
|
2279
|
-
};
|
|
2280
|
-
}
|
|
2281
|
-
spawnCmd = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
|
|
2282
|
-
spawnArgs = [
|
|
2283
|
-
"/d",
|
|
2284
|
-
"/s",
|
|
2285
|
-
"/c",
|
|
2286
|
-
buildWindowsShellCommand(resolved.resolvedPath, args),
|
|
2287
|
-
];
|
|
2288
|
-
windowsVerbatimArguments = true;
|
|
2289
|
-
}
|
|
2290
|
-
else {
|
|
2291
|
-
ensureUtf8ConsoleCodePageOnce();
|
|
2292
|
-
spawnCmd = resolved.resolvedPath;
|
|
2293
|
-
spawnArgs = args;
|
|
2294
|
-
}
|
|
2295
|
-
const result = spawnSync(spawnCmd, spawnArgs, {
|
|
2296
|
-
...options,
|
|
2297
|
-
cwd: spawnCwd,
|
|
2298
|
-
env: spawnEnv,
|
|
2299
|
-
encoding: "utf-8",
|
|
2300
|
-
shell: false,
|
|
2301
|
-
windowsHide: true,
|
|
2302
|
-
windowsVerbatimArguments,
|
|
2303
|
-
});
|
|
2304
|
-
const spawnFailure = result.error
|
|
2305
|
-
? classifySpawnFailureSync(result.error, { command, cwd: options?.cwd })
|
|
2306
|
-
: undefined;
|
|
2307
|
-
return {
|
|
2308
|
-
stdout: result.stdout?.toString() || "",
|
|
2309
|
-
stderr: result.stderr?.toString() || "",
|
|
2310
|
-
status: result.status,
|
|
2311
|
-
error: result.error,
|
|
2312
|
-
...(spawnFailure ? { failure: "spawn", spawnFailure } : {}),
|
|
2313
|
-
};
|
|
2314
|
-
}
|
|
2315
|
-
const result = spawnSync(command, args, {
|
|
2316
|
-
...options,
|
|
2317
|
-
// Explicit override, not just the spread above: `options.env` alone
|
|
2318
|
-
// would otherwise reach the child as a full replacement (no
|
|
2319
|
-
// process.env merge, no PATH) instead of the merged environment the
|
|
2320
|
-
// resolver/Windows branch above both use (#1201) — the module's
|
|
2321
|
-
// "resolver and child receive the same merged environment" invariant
|
|
2322
|
-
// must hold on every platform, not just Windows.
|
|
2323
|
-
env: spawnEnv,
|
|
2324
|
-
encoding: "utf-8",
|
|
2325
|
-
shell: false,
|
|
2326
|
-
windowsHide: true,
|
|
2327
|
-
});
|
|
2328
|
-
const spawnFailure = result.error
|
|
2329
|
-
? classifySpawnFailureSync(result.error, { command, cwd: options?.cwd })
|
|
2330
|
-
: undefined;
|
|
2331
|
-
return {
|
|
2332
|
-
stdout: result.stdout?.toString() || "",
|
|
2333
|
-
stderr: result.stderr?.toString() || "",
|
|
2334
|
-
status: result.status,
|
|
2335
|
-
error: result.error,
|
|
2336
|
-
...(spawnFailure ? { failure: "spawn", spawnFailure } : {}),
|
|
2337
|
-
};
|
|
2338
|
-
}
|
|
2339
|
-
/**
|
|
2340
|
-
* Check if a command is available in PATH (sync version - deprecated)
|
|
2341
|
-
* @deprecated Use isCommandAvailableAsync
|
|
2342
|
-
*/
|
|
2343
|
-
export function isCommandAvailable(command) {
|
|
2344
|
-
const result = safeSpawn(process.platform === "win32" ? "where" : "which", [command], { timeout: 5000 });
|
|
2345
|
-
return result.status === 0;
|
|
2346
|
-
}
|
|
2347
|
-
/**
|
|
2348
|
-
* Find the full path to a command (sync version - deprecated)
|
|
2349
|
-
* @deprecated Use findCommandAsync
|
|
2350
|
-
*/
|
|
2351
|
-
export function findCommand(command) {
|
|
2352
|
-
const finder = process.platform === "win32" ? "where" : "which";
|
|
2353
|
-
const result = safeSpawn(finder, [command], { timeout: 5000 });
|
|
2354
|
-
if (result.status !== 0)
|
|
2355
|
-
return null;
|
|
2356
|
-
return result.stdout.trim().split("\n")[0] || null;
|
|
2357
|
-
}
|