@herbertgao/pi-extensions 2026.9.15 → 2026.9.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
- package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
- package/node_modules/@narumitw/pi-btw/package.json +4 -4
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
- package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
- package/node_modules/pi-lens/CHANGELOG.md +498 -0
- package/node_modules/pi-lens/README.md +45 -2
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
- package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
- package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
- package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
- package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
- package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
- package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
- package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
- package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
- package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
- package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
- package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
- package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
- package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
- package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
- package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
- package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
- package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
- package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
- package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
- package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
- package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
- package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
- package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
- package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
- package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
- package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
- package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
- package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
- package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
- package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
- package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
- package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
- package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
- package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
- package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
- package/node_modules/pi-lens/dist/index.js +5695 -3733
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
- package/node_modules/pi-lens/dist/mcp/server.js +33 -21
- package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
- package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
- package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
- package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
- package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
- package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
- package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
- package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
- package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
- package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
- package/node_modules/pi-lens/docs/adr/README.md +26 -0
- package/node_modules/pi-lens/docs/agent-tools.md +18 -1
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
- package/node_modules/pi-lens/docs/configuration.md +55 -1
- package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
- package/node_modules/pi-lens/docs/environment-variables.md +61 -4
- package/node_modules/pi-lens/docs/features.md +2 -2
- package/node_modules/pi-lens/docs/globalconfig.md +6 -1
- package/node_modules/pi-lens/docs/language-coverage.md +17 -0
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
- package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
- package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
- package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
- package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
- package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
- package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
- package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
- package/node_modules/pi-lens/docs/settings.md +12 -3
- package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
- package/node_modules/pi-lens/package.json +5 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
- package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
- package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
- package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
- package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
- package/node_modules/pi-mcp-adapter/README.md +12 -5
- package/node_modules/pi-mcp-adapter/config.ts +31 -8
- package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
- package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
- package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
- package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
- package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
- package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
- package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
- package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
- package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
- package/node_modules/pi-mcp-adapter/index.ts +37 -20
- package/node_modules/pi-mcp-adapter/init.ts +14 -15
- package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
- package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
- package/node_modules/pi-mcp-adapter/package.json +3 -2
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
- package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
- package/node_modules/pi-mcp-adapter/types.ts +2 -2
- package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
- package/node_modules/pi-mcp-adapter/utils.ts +11 -3
- package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
- package/node_modules/pi-next-cue/LICENSE +21 -0
- package/node_modules/pi-next-cue/README.md +81 -0
- package/node_modules/pi-next-cue/index.ts +358 -0
- package/node_modules/pi-next-cue/package.json +28 -0
- package/package.json +8 -5
|
@@ -22,6 +22,7 @@ import { incrementDegradationCount, recordDegradation, recordDegradationOnce, }
|
|
|
22
22
|
import { logExtension } from "./extension-log.js";
|
|
23
23
|
import { isFullyQualifiedWin32 } from "./path-utils.js";
|
|
24
24
|
import { startSpawnUsageSampler } from "./resource-sampler.js";
|
|
25
|
+
import { DEFAULT_MAX_OUTPUT_BYTES } from "./spawn-output-cap.js";
|
|
25
26
|
import { compareOrdinal } from "./string-utils.js";
|
|
26
27
|
/** Intent-level spawn failure. `cause` retains the original OS Error/errno. */
|
|
27
28
|
export class SpawnFailureError extends Error {
|
|
@@ -479,7 +480,48 @@ function installLifetimeCleanup() {
|
|
|
479
480
|
process.once(signal, () => {
|
|
480
481
|
for (const pid of lifetimeState.pids)
|
|
481
482
|
killPidTreeSync(pid);
|
|
482
|
-
|
|
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
|
+
}
|
|
483
525
|
});
|
|
484
526
|
}
|
|
485
527
|
}
|
|
@@ -1156,6 +1198,24 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1156
1198
|
let killed = false;
|
|
1157
1199
|
let outputTruncated = false;
|
|
1158
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;
|
|
1159
1219
|
let streamingMatch = false;
|
|
1160
1220
|
// #1651 review: a single boolean the close/error handlers both check
|
|
1161
1221
|
// AND set, so whichever one decides the outcome first wins outright —
|
|
@@ -1198,11 +1258,14 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1198
1258
|
// re-arm the grace timer on every chunk. `undefined` before exit / after
|
|
1199
1259
|
// the wait settles, so the calls below are no-ops outside that window.
|
|
1200
1260
|
let rearmIdleGrace;
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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;
|
|
1206
1269
|
const outputTruncationMarker = "\n...[output truncated]...\n";
|
|
1207
1270
|
let retainedHead = [];
|
|
1208
1271
|
let retainedTail = [];
|
|
@@ -1259,17 +1322,16 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1259
1322
|
};
|
|
1260
1323
|
const appendOutput = (stream, current, chunk) => {
|
|
1261
1324
|
const text = typeof chunk === "string" ? chunk : chunk.toString();
|
|
1262
|
-
if (
|
|
1263
|
-
if (maxOutputBytes === undefined)
|
|
1264
|
-
return current + text;
|
|
1325
|
+
if (outputTruncated) {
|
|
1265
1326
|
appendTail({ stream, text }, retainedTailLimit);
|
|
1266
1327
|
truncationStream = stream;
|
|
1267
1328
|
return renderOutput(stream);
|
|
1268
1329
|
}
|
|
1269
|
-
const used = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
|
|
1270
1330
|
const bytes = Buffer.byteLength(text);
|
|
1271
|
-
if (
|
|
1331
|
+
if (retainedOutputBytes + bytes <= maxOutputBytes) {
|
|
1332
|
+
retainedOutputBytes += bytes;
|
|
1272
1333
|
return current + text;
|
|
1334
|
+
}
|
|
1273
1335
|
outputTruncated = true;
|
|
1274
1336
|
truncationStream = stream;
|
|
1275
1337
|
const available = Math.max(0, maxOutputBytes - Buffer.byteLength(outputTruncationMarker));
|
|
@@ -1501,10 +1563,48 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1501
1563
|
usageSampler = { stop: () => null };
|
|
1502
1564
|
}
|
|
1503
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
|
+
};
|
|
1504
1604
|
// On Windows, shell:true means child.pid is cmd.exe — child.kill() only
|
|
1505
1605
|
// kills the wrapper, leaving the actual subprocess (e.g. knip/npx) alive
|
|
1506
1606
|
// as an orphan. Use taskkill /F /T to kill the full process tree instead.
|
|
1507
|
-
const killTree = async () => {
|
|
1607
|
+
const killTree = async (phase) => {
|
|
1508
1608
|
if (isWindows && child.pid && child.pid > 0) {
|
|
1509
1609
|
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
|
|
1510
1610
|
try {
|
|
@@ -1516,13 +1616,17 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1516
1616
|
});
|
|
1517
1617
|
killer.once("close", () => done());
|
|
1518
1618
|
killer.once("error", () => {
|
|
1519
|
-
|
|
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);
|
|
1520
1623
|
done();
|
|
1521
1624
|
});
|
|
1522
1625
|
});
|
|
1523
1626
|
}
|
|
1524
1627
|
catch {
|
|
1525
|
-
child.kill("SIGKILL")
|
|
1628
|
+
if (!trySend(() => child.kill("SIGKILL")))
|
|
1629
|
+
noteKillFailure(phase);
|
|
1526
1630
|
}
|
|
1527
1631
|
}
|
|
1528
1632
|
else if (posixProcessGroup && ownsChildPid) {
|
|
@@ -1530,11 +1634,18 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1530
1634
|
// by the tool share its group, so one signal reaches the whole
|
|
1531
1635
|
// tree; a negative-pid ESRCH means it already exited.
|
|
1532
1636
|
const pgid = -child.pid;
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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);
|
|
1538
1649
|
}
|
|
1539
1650
|
// #2027 round-1: gate the SIGKILL escalation on GROUP liveness,
|
|
1540
1651
|
// not direct-child death - a tool can exit instantly on SIGTERM
|
|
@@ -1543,38 +1654,49 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1543
1654
|
// it is the only backstop for this group once finalize deletes
|
|
1544
1655
|
// the pid from lifetimeState.
|
|
1545
1656
|
escalationTimer = setTimeout(() => {
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
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"));
|
|
1562
1683
|
});
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
}
|
|
1566
|
-
catch {
|
|
1567
|
-
// Raced with group exit.
|
|
1568
|
-
}
|
|
1684
|
+
if (!escalated)
|
|
1685
|
+
noteKillFailure(phase);
|
|
1569
1686
|
}, 1000);
|
|
1570
1687
|
}
|
|
1571
1688
|
else {
|
|
1572
|
-
|
|
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);
|
|
1573
1694
|
escalationTimer = setTimeout(() => {
|
|
1574
|
-
if (
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1695
|
+
if (closed)
|
|
1696
|
+
return;
|
|
1697
|
+
teardownEscalated = true;
|
|
1698
|
+
if (!trySend(() => child.kill("SIGKILL")))
|
|
1699
|
+
noteKillFailure(phase);
|
|
1578
1700
|
}, 1000);
|
|
1579
1701
|
}
|
|
1580
1702
|
};
|
|
@@ -1583,7 +1705,7 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1583
1705
|
aborted = true;
|
|
1584
1706
|
if (!killed && !child.killed) {
|
|
1585
1707
|
killed = true;
|
|
1586
|
-
void killTree();
|
|
1708
|
+
void killTree("abort");
|
|
1587
1709
|
}
|
|
1588
1710
|
};
|
|
1589
1711
|
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -1600,28 +1722,95 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1600
1722
|
!child.killed) {
|
|
1601
1723
|
killedForOutputCap = true;
|
|
1602
1724
|
killed = true;
|
|
1603
|
-
killPromise = killTree();
|
|
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");
|
|
1604
1777
|
}
|
|
1605
1778
|
};
|
|
1606
1779
|
// Collect output
|
|
1607
1780
|
child.stdout?.setEncoding("utf-8");
|
|
1608
1781
|
child.stderr?.setEncoding("utf-8");
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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));
|
|
1625
1814
|
// #1673 review round 3 (F2): registered HERE, at spawn time, alongside
|
|
1626
1815
|
// the other handlers above — not inside `waitForPipeIdle`. Real Node
|
|
1627
1816
|
// emits `close` a fraction of a millisecond after `exit` (both fire in
|
|
@@ -1650,7 +1839,7 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1650
1839
|
if (!killed && !child.killed) {
|
|
1651
1840
|
killed = true;
|
|
1652
1841
|
teardownStartedAtMs = Date.now();
|
|
1653
|
-
killPromise = killTree();
|
|
1842
|
+
killPromise = killTree("timeout");
|
|
1654
1843
|
}
|
|
1655
1844
|
}, timeout);
|
|
1656
1845
|
// #620: stop the poll and log peak/average CPU%+RSS for this invocation
|
|
@@ -1817,6 +2006,7 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1817
2006
|
const outputInfo = {
|
|
1818
2007
|
...(outputTruncated ? { outputTruncated: true } : {}),
|
|
1819
2008
|
...(killedForOutputCap ? { killedForOutputCap: true } : {}),
|
|
2009
|
+
...(killFailed ? { killFailed: true } : {}),
|
|
1820
2010
|
};
|
|
1821
2011
|
const streamingMatchInfo = streamingMatch ? { streamingMatch: true } : {};
|
|
1822
2012
|
// #1816: surface the signal name as a field on every path where one
|
|
@@ -1960,6 +2150,7 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1960
2150
|
spawnFailure,
|
|
1961
2151
|
...(outputTruncated ? { outputTruncated: true } : {}),
|
|
1962
2152
|
...(killedForOutputCap ? { killedForOutputCap: true } : {}),
|
|
2153
|
+
...(killFailed ? { killFailed: true } : {}),
|
|
1963
2154
|
...(streamingMatch ? { streamingMatch: true } : {}),
|
|
1964
2155
|
resourceUsage,
|
|
1965
2156
|
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one reading of `SpawnResult.outputTruncated` (#2100)
|
|
2
|
+
* The one reading of `SpawnResult.outputTruncated` (#2100), the one output-cap
|
|
3
|
+
* number (#3375), and the bounded accumulator the `data` handlers that never
|
|
4
|
+
* reach `safeSpawnAsync` use instead of `+=` (#3383).
|
|
3
5
|
*
|
|
4
6
|
* Its own module for the same reason `ledger-bounds.ts` is: `spawn-outcome.ts`
|
|
5
7
|
* is on the shared runner path that dozens of test files reach with a bare
|
|
@@ -7,6 +9,87 @@
|
|
|
7
9
|
* every one of those mocks have to re-export it. This module has no imports, so
|
|
8
10
|
* nobody has to mock it.
|
|
9
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* The cap `safeSpawnAsync` applies when a caller passes no `maxOutputBytes`
|
|
14
|
+
* (#3375).
|
|
15
|
+
*
|
|
16
|
+
* Before this existed, an omitted cap meant NO cap: `appendOutput` fell
|
|
17
|
+
* through to `current + text` and grew one JS string until V8 refused the next
|
|
18
|
+
* concatenation with `RangeError: Invalid string length`. That throw is raised
|
|
19
|
+
* inside a stdout/stderr `data` handler, where the awaiting caller's
|
|
20
|
+
* `try`/`catch` cannot reach it, so it left the Pi host as an uncaught
|
|
21
|
+
* exception (field report: Pi 0.86.1, 2026-09-22).
|
|
22
|
+
*
|
|
23
|
+
* 32 MiB is chosen against the tree's own deliberate ceilings, not invented:
|
|
24
|
+
* the most generous explicit cap any caller asks for is 16 MiB
|
|
25
|
+
* (`MAX_GIT_STATUS_OUTPUT_BYTES` in `clients/shared-checkout-guard.ts` and
|
|
26
|
+
* `clients/opaque-mutation-scan.ts`, `MAX_LS_FILES_OUTPUT_BYTES` in
|
|
27
|
+
* `clients/git-tracked-ignore.ts` — a monorepo's whole tracked-file list), and
|
|
28
|
+
* the rest sit at 8 MiB or 64 KiB. Doubling that ceiling means no consumer
|
|
29
|
+
* whose legitimate volume is within the most generous allowance a maintainer
|
|
30
|
+
* has ever justified can be truncated by the DEFAULT, while the retained
|
|
31
|
+
* string stays 16x below V8's max string length (2^29-24 bytes of ASCII), so
|
|
32
|
+
* the concatenation that crashed the host is now unreachable rather than
|
|
33
|
+
* merely less likely. A caller that genuinely needs more passes its own
|
|
34
|
+
* `maxOutputBytes`; it always wins.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
37
|
+
/**
|
|
38
|
+
* The bound for a stream `data` handler that accumulates a string OUTSIDE
|
|
39
|
+
* `safeSpawnAsync` (#3383).
|
|
40
|
+
*
|
|
41
|
+
* #3375 capped the shared spawn seam, which covered 98 of 110 call sites; the
|
|
42
|
+
* class sweep for it found five accumulators that never reach that seam — a
|
|
43
|
+
* forked analyze worker (`clients/mcp/review.ts`), the unref'd one-shot
|
|
44
|
+
* collector (`clients/child-unref.ts`, which cannot import the seam at all: it
|
|
45
|
+
* is what `safe-spawn.ts` itself depends on) and two installer interpreter
|
|
46
|
+
* probes. Each grew one JS string per chunk with no ceiling, so each could
|
|
47
|
+
* reach the identical ending: V8 refusing the next concatenation with
|
|
48
|
+
* `RangeError: Invalid string length` INSIDE a `data` handler, which is an
|
|
49
|
+
* uncaught exception that terminated the Pi host on 2026-09-22.
|
|
50
|
+
*
|
|
51
|
+
* Retention is HEAD-ONLY and whole-chunk: the first chunk that would cross the
|
|
52
|
+
* bound is dropped entire, not byte-prefixed. `safeSpawnAsync`'s own
|
|
53
|
+
* `appendOutput` keeps a head, a marker and a tail because its callers parse
|
|
54
|
+
* tool output where the tail carries the summary; every caller here wants a
|
|
55
|
+
* prefix (a JSON document, a `ps` table, one path line) and a whole-chunk drop
|
|
56
|
+
* cannot split a multi-byte character.
|
|
57
|
+
*
|
|
58
|
+
* `maxBytes` defaults to {@link DEFAULT_MAX_OUTPUT_BYTES} so every bounded
|
|
59
|
+
* accumulator in the tree answers to ONE reviewed number. NO production caller
|
|
60
|
+
* passes it; it is a parameter so the retention arithmetic can be pinned at a
|
|
61
|
+
* readable size (`createBoundedOutputSink(10)`) instead of only at 32 MiB.
|
|
62
|
+
* There is deliberately no sanitizing of it either: nothing passes a value that
|
|
63
|
+
* would need it, so a `Number.isFinite` arm would be mutation-inert.
|
|
64
|
+
*/
|
|
65
|
+
export function createBoundedOutputSink(maxBytes = DEFAULT_MAX_OUTPUT_BYTES) {
|
|
66
|
+
let text = "";
|
|
67
|
+
let retained = 0;
|
|
68
|
+
let truncated = false;
|
|
69
|
+
let observed = 0;
|
|
70
|
+
return {
|
|
71
|
+
append(chunk) {
|
|
72
|
+
const part = typeof chunk === "string" ? chunk : chunk.toString();
|
|
73
|
+
const bytes = Buffer.byteLength(part);
|
|
74
|
+
observed += bytes;
|
|
75
|
+
if (retained + bytes <= maxBytes) {
|
|
76
|
+
retained += bytes;
|
|
77
|
+
text += part;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
truncated = true;
|
|
81
|
+
},
|
|
82
|
+
get text() {
|
|
83
|
+
return text;
|
|
84
|
+
},
|
|
85
|
+
get truncated() {
|
|
86
|
+
return truncated;
|
|
87
|
+
},
|
|
88
|
+
get observedBytes() {
|
|
89
|
+
return observed;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
10
93
|
/**
|
|
11
94
|
* True when `outputTruncated` is the OUTPUT CAP's own verdict about this run,
|
|
12
95
|
* and not a detail of some other ending.
|