Repository navigation
fix: capture child and descriptor stdout in JSON envelopes #420
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
codeforester
merged 77 commits into
main
from
bug/379-20261003-bug-json-single-envelope-stdout-contract-is-broken-by-any-wr
Oct 5, 2026
Merged
Changes from all commits
Commits
Show all changes
77 commits
Select commit
Hold shift + click to select a range
30e9e71
ci: cover all consumer fixtures in style and strict typing gates
codeforester 710208b
ci: keep Markdown examples under the documentation gate
codeforester 8bb0562
fix: preserve consumer logging ownership and routing
codeforester 6556889
perf: reuse logging locks and cache source paths
codeforester c87eeda
fix: enforce native Windows bundle retention safely
codeforester f0f9ece
ci: satisfy Windows retention style and typing checks
codeforester 9679928
perf: skip contended retention housekeeping
codeforester 6280ebb
security: bound and validate discovered project configuration
codeforester 7db92d8
test: exercise recursion failures at bounded YAML composition
codeforester 5e84077
fix: capture child and descriptor stdout in JSON envelopes
codeforester a166e45
perf: cache second-precision human log timestamps
codeforester ee489ee
fix: preserve Python newline bytes in descriptor capture
codeforester baf0ff3
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester c7c21ae
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester 40b1401
Merge branch 'enhancement/386-20261003-perf-retention-serializes-conc…
codeforester 74d2f5c
Merge branch 'security/385-20261003-security-validate-trust-of-ancest…
codeforester d352e43
Merge remote-tracking branch 'origin/main' into ci/393-20261003-ci-ru…
codeforester 6ee5cc9
Merge branch 'ci/393-20261003-ci-ruff-check-and-mypy-do-not-cover-the…
codeforester f9787b1
Merge branch 'bug/387-20261003-bug-configure-logger-closes-consumer-o…
codeforester 3780821
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester 333ac40
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester 4f08c7d
Merge branch 'enhancement/386-20261003-perf-retention-serializes-conc…
codeforester 369f986
Merge branch 'security/385-20261003-security-validate-trust-of-ancest…
codeforester 6e4394f
fix: avoid racing Windows lock-sidecar initialization
codeforester bd210cd
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester f1bda9a
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester a1683c4
Merge branch 'enhancement/386-20261003-perf-retention-serializes-conc…
codeforester 5f39a1f
Merge branch 'security/385-20261003-security-validate-trust-of-ancest…
codeforester 73770a5
ci: separate sustained persistence cost from hosted filesystem tails
codeforester bdf26d9
Merge branch 'ci/393-20261003-ci-ruff-check-and-mypy-do-not-cover-the…
codeforester c3eb5dc
Merge branch 'bug/387-20261003-bug-configure-logger-closes-consumer-o…
codeforester 0597aaa
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester b24372d
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester d1fd067
Merge branch 'enhancement/386-20261003-perf-retention-serializes-conc…
codeforester 5ba2267
Merge branch 'security/385-20261003-security-validate-trust-of-ancest…
codeforester 8ca0027
docs: regenerate public API signatures for configuration trust options
codeforester 9191a7e
Merge branch 'bug/387-20261003-bug-configure-logger-closes-consumer-o…
codeforester b7fd5df
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester 331e583
Merge branch 'enhancement/386-20261003-perf-retention-serializes-conc…
codeforester 6ba56eb
Merge branch 'security/385-20261003-security-validate-trust-of-ancest…
codeforester 5d8860e
docs: regenerate public API signatures for configuration trust options
codeforester 6e9d231
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester 857e52e
Merge main for branch maintenance (#426)
codeforester b48e444
Merge updated PR #414 for branch maintenance (#426)
codeforester 0441cec
Merge updated PR #419 for branch maintenance (#426)
codeforester 9012457
Merge updated PR #418 for branch maintenance (#426)
codeforester c4af745
Merge updated PR #417 for branch maintenance (#426)
codeforester 66ce268
Merge updated PR #415 for branch maintenance (#426)
codeforester e82ce1f
Merge updated PR #416 for branch maintenance (#426)
codeforester 2a0a9bb
fix: preserve incomplete JSON stdout captures
codeforester 3d397ae
security: clarify discovered config verification
codeforester b00c288
fix: defer contended retention index refresh
codeforester 9db3464
fix: preserve logger routing and levels
codeforester fbcc75c
fix: harden logging caches and sidecar locks
codeforester 841d1e6
fix: handle Windows retention reparse leaves
codeforester 72336ec
ci: expose consumer typing source coverage
codeforester 3b4fc3f
docs: document process-wide JSON capture
codeforester 311c280
test: verify retention converges after contention
codeforester ae935d1
fix: serialize logger reconfiguration
codeforester ff3f148
security: make trust errors actionable
codeforester 97135b0
fix: type incomplete capture failures explicitly
codeforester f15f2c2
style: clean retention convergence test
codeforester 990cb66
style: format typing gate test
codeforester 50b0695
style: format sidecar lock condition
codeforester c8e829d
Merge remote-tracking branch 'origin/enhancement/381-20261003-perf-li…
codeforester 496decf
style: format retention lock logging
codeforester 0f35aae
Merge remote-tracking branch 'origin/main' into ci/393-20261003-ci-ru…
codeforester 6efcb60
test: cover debug logging with consumer handlers
codeforester 436807b
test: cover concurrent and inherited logging state
codeforester 2a916ea
test: verify Windows retention pinning and reparse cleanup
codeforester 3f58457
Merge remote-tracking branch 'origin/ci/393-20261003-ci-ruff-check-an…
codeforester 92cad10
Merge remote-tracking branch 'origin/bug/387-20261003-bug-configure-l…
codeforester e15763e
Merge remote-tracking branch 'origin/enhancement/381-20261003-perf-li…
codeforester 2f96072
Merge remote-tracking branch 'origin/bug/378-20261003-bug-run-bundle-…
codeforester 5e8e01f
Merge remote-tracking branch 'origin/enhancement/386-20261003-perf-re…
codeforester 40d9e2a
Merge remote-tracking branch 'origin/security/385-20261003-security-v…
codeforester b94249c
Merge main into JSON envelope capture fix
codeforester File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| """Capture process stdout, including inherited child descriptors, for JSON runs.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import codecs | ||
| import os | ||
| import sys | ||
| import tempfile | ||
| from collections.abc import Iterator | ||
| from contextlib import contextmanager, redirect_stdout | ||
| from threading import Event, Lock, Thread | ||
| from typing import TextIO | ||
|
|
||
|
|
||
| class StdoutCaptureIncompleteError(RuntimeError): | ||
| """Raised when a child keeps stdout open past the capture deadline.""" | ||
|
|
||
|
|
||
| @contextmanager | ||
| def capture_stdout(sink: TextIO, limit: int, limit_error: type[Exception]) -> Iterator[None]: | ||
| """Drain fd 1 concurrently, restore it, then replay through the JSON limiter. | ||
|
|
||
| The invocation owns the process output boundary. Children must be waited for | ||
| before returning; a child retaining stdout is a deterministic capture error. | ||
| """ | ||
| original = sys.stdout | ||
| original.flush() | ||
| saved = os.dup(1) | ||
| read_fd, write_fd = os.pipe() | ||
| spool = tempfile.SpooledTemporaryFile(max_size=1_048_576, mode="w+b") | ||
| abandoned = Event() | ||
| errors: list[BaseException] = [] | ||
| spool_lock = Lock() | ||
| overflow = False | ||
|
|
||
| def drain() -> None: | ||
| nonlocal overflow | ||
| total = 0 | ||
| try: | ||
| with os.fdopen(read_fd, "rb", buffering=0) as reader: | ||
| while chunk := reader.read(65536): | ||
| if abandoned.is_set(): | ||
| break | ||
| total += len(chunk) | ||
| # Unresolved parser output retains the existing deferred | ||
| # spool semantics. Once JSON is selected, native writers | ||
| # are bounded too; continue draining to avoid child deadlock. | ||
| json_mode = not hasattr(sink, "json_output") or bool(sink.json_output) | ||
| if json_mode and total > limit: | ||
| overflow = True | ||
| continue | ||
| with spool_lock: | ||
| spool.write(chunk) | ||
| except BaseException as exc: | ||
| errors.append(exc) | ||
| finally: | ||
| if abandoned.is_set(): | ||
| spool.close() | ||
|
|
||
| worker = Thread(target=drain, name="base-cli-stdout-capture", daemon=True) | ||
| writer: TextIO | None = None | ||
| try: | ||
| worker.start() | ||
| os.dup2(write_fd, 1) | ||
| os.close(write_fd) | ||
| write_fd = -1 | ||
| writer = os.fdopen(os.dup(1), "w", encoding="utf-8", errors="strict", buffering=1, newline="") | ||
| with redirect_stdout(writer): | ||
| try: | ||
| yield | ||
| finally: | ||
| writer.flush() | ||
| # sys.__stdout__ can have its own Python buffering. | ||
| if sys.__stdout__ is not None and sys.__stdout__ is not writer: | ||
| try: | ||
| sys.__stdout__.flush() | ||
| except (OSError, ValueError): | ||
| pass | ||
| finally: | ||
| try: | ||
| if writer is not None: | ||
| writer.close() | ||
| finally: | ||
| os.dup2(saved, 1) | ||
| os.close(saved) | ||
| if write_fd != -1: | ||
| os.close(write_fd) | ||
| worker.join(timeout=2) | ||
|
codeforester marked this conversation as resolved.
|
||
| if worker.is_alive(): | ||
| # Preserve everything drained before the timeout. The descriptor | ||
| # may remain open in a detached child, so this is an incomplete | ||
| # capture rather than a stdout-size overflow. | ||
| with spool_lock: | ||
| spool.seek(0) | ||
| decoder = codecs.getincrementaldecoder("utf-8")("replace") | ||
| while chunk := spool.read(65536): | ||
| sink.write(decoder.decode(chunk)) | ||
| sink.write(decoder.decode(b"", final=True)) | ||
| sink.flush() | ||
| abandoned.set() | ||
| raise StdoutCaptureIncompleteError( | ||
| "A child retained stdout after the command returned; captured output is incomplete. " | ||
| "Wait for child processes or redirect detached children to DEVNULL." | ||
| ) | ||
| try: | ||
| if errors: | ||
| raise OSError("Could not capture process stdout") from errors[0] | ||
| if overflow: | ||
| size = f"{limit // 1_048_576} MiB" if limit % 1_048_576 == 0 else f"{limit} bytes" | ||
| raise limit_error(f"JSON stdout exceeded the {size} limit; use NDJSON for large record sets.") | ||
| spool.seek(0) | ||
| decoder = codecs.getincrementaldecoder("utf-8")("replace") | ||
| while chunk := spool.read(65536): | ||
| sink.write(decoder.decode(chunk)) | ||
| sink.write(decoder.decode(b"", final=True)) | ||
| finally: | ||
| spool.close() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| import base_cli | ||
|
|
||
|
|
||
| def _run(tmp_path: Path, body: str, args: list[str]) -> subprocess.CompletedProcess[str]: | ||
| script = ( | ||
| """ | ||
| import os, subprocess, sys | ||
| import base_cli | ||
| app = base_cli.App(name='descriptor-capture', lifecycle_options=base_cli.LifecycleOptions(json=base_cli.LifecycleOption('--json'))) | ||
| @app.command() | ||
| def main(ctx): | ||
| """ | ||
| + "\n".join(" " + line for line in body.splitlines()) | ||
| + "\nraise SystemExit(base_cli.run_app(app))\n" | ||
| ) | ||
| return subprocess.run( | ||
| [sys.executable, "-c", script, *args], | ||
| text=True, | ||
| capture_output=True, | ||
| timeout=15, | ||
| env={ | ||
| **os.environ, | ||
| "BASE_CLI_CACHE_DIR": str(tmp_path), | ||
| "PYTHONPATH": str(Path(base_cli.__file__).resolve().parents[1]), | ||
| }, | ||
| ) | ||
|
|
||
|
|
||
| def test_json_captures_inherited_subprocess_and_descriptor_writers(tmp_path: Path) -> None: | ||
| result = _run( | ||
| tmp_path, | ||
| """print('python', flush=True) | ||
| os.write(1, b'descriptor\\n') | ||
| subprocess.run([sys.executable, '-c', "print('child')"], check=True) | ||
| sys.__stdout__.write('original\\n') | ||
| """, | ||
| ["--json"], | ||
| ) | ||
| assert result.returncode == 0, result.stderr | ||
| envelope = json.loads(result.stdout) | ||
| captured = envelope["details"]["stdout"] | ||
| assert all(word in captured for word in ("python", "descriptor", "child", "original")) | ||
|
|
||
|
|
||
| def test_native_output_limit_is_a_single_error_envelope(tmp_path: Path) -> None: | ||
| result = _run(tmp_path, "os.write(1, b'x' * (9 * 1024 * 1024))", ["--json"]) | ||
| assert result.returncode != 0 | ||
| envelope = json.loads(result.stdout) | ||
| assert "limit" in str(envelope) | ||
|
|
||
|
|
||
| def test_human_stdout_is_unchanged(tmp_path: Path) -> None: | ||
| result = _run(tmp_path, "subprocess.run([sys.executable, '-c', 'print(42)'], check=True)", []) | ||
| assert result.returncode == 0 | ||
| assert result.stdout == "42\n" | ||
|
|
||
|
|
||
| def test_detached_child_reports_incomplete_capture_with_partial_stdout(tmp_path: Path) -> None: | ||
| result = _run( | ||
| tmp_path, | ||
| """child = subprocess.Popen([sys.executable, '-c', 'import time; print(\\"child\\", flush=True); time.sleep(5)']) | ||
| print('parent', flush=True) | ||
| """, | ||
| ["--json"], | ||
| ) | ||
| assert result.returncode != 0 | ||
| envelope = json.loads(result.stdout) | ||
| assert envelope["code"] == "capture_incomplete" | ||
| assert "parent" in envelope["details"]["stdout"] | ||
| assert "incomplete" in envelope["message"] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.