Skip to content
Merged
Show file tree
Hide file tree
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 Oct 2, 2026
710208b
ci: keep Markdown examples under the documentation gate
codeforester Oct 2, 2026
8bb0562
fix: preserve consumer logging ownership and routing
codeforester Oct 2, 2026
6556889
perf: reuse logging locks and cache source paths
codeforester Oct 2, 2026
c87eeda
fix: enforce native Windows bundle retention safely
codeforester Oct 2, 2026
f0f9ece
ci: satisfy Windows retention style and typing checks
codeforester Oct 2, 2026
9679928
perf: skip contended retention housekeeping
codeforester Oct 2, 2026
6280ebb
security: bound and validate discovered project configuration
codeforester Oct 2, 2026
7db92d8
test: exercise recursion failures at bounded YAML composition
codeforester Oct 2, 2026
5e84077
fix: capture child and descriptor stdout in JSON envelopes
codeforester Oct 2, 2026
a166e45
perf: cache second-precision human log timestamps
codeforester Oct 2, 2026
ee489ee
fix: preserve Python newline bytes in descriptor capture
codeforester Oct 2, 2026
baf0ff3
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester Oct 2, 2026
c7c21ae
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester Oct 2, 2026
40b1401
Merge branch 'enhancement/386-20261003-perf-retention-serializes-conc…
codeforester Oct 2, 2026
74d2f5c
Merge branch 'security/385-20261003-security-validate-trust-of-ancest…
codeforester Oct 2, 2026
d352e43
Merge remote-tracking branch 'origin/main' into ci/393-20261003-ci-ru…
codeforester Oct 2, 2026
6ee5cc9
Merge branch 'ci/393-20261003-ci-ruff-check-and-mypy-do-not-cover-the…
codeforester Oct 2, 2026
f9787b1
Merge branch 'bug/387-20261003-bug-configure-logger-closes-consumer-o…
codeforester Oct 2, 2026
3780821
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester Oct 2, 2026
333ac40
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester Oct 2, 2026
4f08c7d
Merge branch 'enhancement/386-20261003-perf-retention-serializes-conc…
codeforester Oct 2, 2026
369f986
Merge branch 'security/385-20261003-security-validate-trust-of-ancest…
codeforester Oct 2, 2026
6e4394f
fix: avoid racing Windows lock-sidecar initialization
codeforester Oct 2, 2026
bd210cd
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester Oct 2, 2026
f1bda9a
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester Oct 2, 2026
a1683c4
Merge branch 'enhancement/386-20261003-perf-retention-serializes-conc…
codeforester Oct 2, 2026
5f39a1f
Merge branch 'security/385-20261003-security-validate-trust-of-ancest…
codeforester Oct 2, 2026
73770a5
ci: separate sustained persistence cost from hosted filesystem tails
codeforester Oct 2, 2026
bdf26d9
Merge branch 'ci/393-20261003-ci-ruff-check-and-mypy-do-not-cover-the…
codeforester Oct 2, 2026
c3eb5dc
Merge branch 'bug/387-20261003-bug-configure-logger-closes-consumer-o…
codeforester Oct 2, 2026
0597aaa
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester Oct 2, 2026
b24372d
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester Oct 2, 2026
d1fd067
Merge branch 'enhancement/386-20261003-perf-retention-serializes-conc…
codeforester Oct 2, 2026
5ba2267
Merge branch 'security/385-20261003-security-validate-trust-of-ancest…
codeforester Oct 2, 2026
8ca0027
docs: regenerate public API signatures for configuration trust options
codeforester Oct 2, 2026
9191a7e
Merge branch 'bug/387-20261003-bug-configure-logger-closes-consumer-o…
codeforester Oct 2, 2026
b7fd5df
Merge branch 'enhancement/381-20261003-perf-lifecycle-logging-costs-1…
codeforester Oct 2, 2026
331e583
Merge branch 'enhancement/386-20261003-perf-retention-serializes-conc…
codeforester Oct 2, 2026
6ba56eb
Merge branch 'security/385-20261003-security-validate-trust-of-ancest…
codeforester Oct 2, 2026
5d8860e
docs: regenerate public API signatures for configuration trust options
codeforester Oct 2, 2026
6e9d231
Merge branch 'bug/378-20261003-bug-run-bundle-retention-is-inoperativ…
codeforester Oct 2, 2026
857e52e
Merge main for branch maintenance (#426)
codeforester Oct 4, 2026
b48e444
Merge updated PR #414 for branch maintenance (#426)
codeforester Oct 4, 2026
0441cec
Merge updated PR #419 for branch maintenance (#426)
codeforester Oct 4, 2026
9012457
Merge updated PR #418 for branch maintenance (#426)
codeforester Oct 4, 2026
c4af745
Merge updated PR #417 for branch maintenance (#426)
codeforester Oct 4, 2026
66ce268
Merge updated PR #415 for branch maintenance (#426)
codeforester Oct 4, 2026
e82ce1f
Merge updated PR #416 for branch maintenance (#426)
codeforester Oct 4, 2026
2a0a9bb
fix: preserve incomplete JSON stdout captures
codeforester Oct 5, 2026
3d397ae
security: clarify discovered config verification
codeforester Oct 5, 2026
b00c288
fix: defer contended retention index refresh
codeforester Oct 5, 2026
9db3464
fix: preserve logger routing and levels
codeforester Oct 5, 2026
fbcc75c
fix: harden logging caches and sidecar locks
codeforester Oct 5, 2026
841d1e6
fix: handle Windows retention reparse leaves
codeforester Oct 5, 2026
72336ec
ci: expose consumer typing source coverage
codeforester Oct 5, 2026
3b4fc3f
docs: document process-wide JSON capture
codeforester Oct 5, 2026
311c280
test: verify retention converges after contention
codeforester Oct 5, 2026
ae935d1
fix: serialize logger reconfiguration
codeforester Oct 5, 2026
ff3f148
security: make trust errors actionable
codeforester Oct 5, 2026
97135b0
fix: type incomplete capture failures explicitly
codeforester Oct 5, 2026
f15f2c2
style: clean retention convergence test
codeforester Oct 5, 2026
990cb66
style: format typing gate test
codeforester Oct 5, 2026
50b0695
style: format sidecar lock condition
codeforester Oct 5, 2026
c8e829d
Merge remote-tracking branch 'origin/enhancement/381-20261003-perf-li…
codeforester Oct 5, 2026
496decf
style: format retention lock logging
codeforester Oct 5, 2026
0f35aae
Merge remote-tracking branch 'origin/main' into ci/393-20261003-ci-ru…
codeforester Oct 5, 2026
6efcb60
test: cover debug logging with consumer handlers
codeforester Oct 5, 2026
436807b
test: cover concurrent and inherited logging state
codeforester Oct 5, 2026
2a916ea
test: verify Windows retention pinning and reparse cleanup
codeforester Oct 5, 2026
3f58457
Merge remote-tracking branch 'origin/ci/393-20261003-ci-ruff-check-an…
codeforester Oct 5, 2026
92cad10
Merge remote-tracking branch 'origin/bug/387-20261003-bug-configure-l…
codeforester Oct 5, 2026
e15763e
Merge remote-tracking branch 'origin/enhancement/381-20261003-perf-li…
codeforester Oct 5, 2026
2f96072
Merge remote-tracking branch 'origin/bug/378-20261003-bug-run-bundle-…
codeforester Oct 5, 2026
5e8e01f
Merge remote-tracking branch 'origin/enhancement/386-20261003-perf-re…
codeforester Oct 5, 2026
40d9e2a
Merge remote-tracking branch 'origin/security/385-20261003-security-v…
codeforester Oct 5, 2026
b94249c
Merge main into JSON envelope capture fix
codeforester Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ and versions are tracked in the repo-root `VERSION` file.

### Fixed

- Capture inherited subprocess and descriptor-1 output inside the single JSON envelope, with bounded native-output handling (#379).
- Enforce native Windows run-bundle retention with pinned directory handles; unsupported platforms fail closed once per pass (#378).
- Preserve consumer-owned logging handlers, explicit levels, and parent routing across CLI invocations (#387).
- Validate nested configuration mappings before merge/provenance traversal,
Expand Down
27 changes: 26 additions & 1 deletion docs/json-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ in memory and rolls the remainder to a temporary file, so both temporary-disk
use and finalization memory remain bounded. The temporary file is removed when
the invocation ends.

The JSON capture boundary temporarily redirects process-wide file descriptor 1.
`run_app()` therefore rejects concurrent invocations in one process; callers
that need parallel CLI work should use separate processes or serialize the
invocations.

The mode check respects Click option arity: a value such as
`--payload --json` does not activate JSON when `--json` is the payload. It does
not run consumer callbacks, defaults, type converters, or close hooks as a
Expand All @@ -35,6 +40,12 @@ If a command exceeds the limit, base-cli emits one `base-cli.error` envelope
with `code: "capture_limit"` and exit code `1`; it never silently truncates
the captured text. Use the NDJSON contract for larger record sets.

If a child retains the inherited stdout descriptor after the command returns,
base-cli emits `code: "capture_incomplete"` and includes the output drained
before the timeout in the error envelope. Detached children should use
`subprocess.DEVNULL` for stdout/stderr (and may use `start_new_session=True`)
when running under JSON mode.

## Output and errors

Both envelopes use `schema_version: 1` and stable fields:
Expand All @@ -53,7 +64,7 @@ Both envelopes use `schema_version: 1` and stable fields:

Failures use `schema: "base-cli.error"`, `type: "error"`, and a deterministic
`code` derived from the lifecycle outcome (`usage_error`, `click_error`,
`capture_limit`, `aborted`, `interrupted`, `unexpected_error`, and so on). `details` always
`capture_limit`, `capture_incomplete`, `aborted`, `interrupted`, `unexpected_error`, and so on). `details` always
contains the numeric `exit_code` and captured command stdout. A command's
human output is represented as a JSON string, so it cannot introduce prose or
ANSI escapes as a second stdout record.
Expand Down Expand Up @@ -156,3 +167,17 @@ Each line is a JSON object with `schema_version`, `schema`, `timestamp` (UTC),
bounds default-log retention to the most recent 20 run bundles (or the
explicit `RetentionPolicy` setting). The legacy `max_log_files` option remains
available for compatibility. JSON logs never use terminal color codes.

### Child processes and native stdout

JSON mode captures Python stdout, `os.write(1, ...)`, `sys.__stdout__`, and
subprocesses inheriting descriptor 1. The descriptor is restored before emitting
the single envelope. Use `subprocess.run([...], check=True)` or explicitly wait
for each `Popen` child before returning. A child retaining stdout after return
produces a capture error after a bounded wait. Native libraries must flush their
own stdio buffers before returning; writes after the invocation boundary cannot
be captured. Invalid UTF-8 bytes are represented with Unicode replacement characters.

The 8 MiB JSON capture limit applies to native/child output as well. Exceeding it
produces an error envelope rather than a success with silently truncated output.
NDJSON and human output keep their streaming behavior.
14 changes: 14 additions & 0 deletions docs/strict-json-consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,17 @@ The schemas and fixtures are the source of truth. Do not add a new parser or
redefine the wire contract in an adopter guide; link the exact contract version
and record the `base-cli` release used for validation. Never place secrets or
private paths in fixtures or public failure reports.

### Child processes and native stdout

JSON mode captures Python stdout, `os.write(1, ...)`, `sys.__stdout__`, and
subprocesses inheriting descriptor 1. The descriptor is restored before emitting
the single envelope. Use `subprocess.run([...], check=True)` or explicitly wait
for each `Popen` child before returning. A child retaining stdout after return
produces a capture error after a bounded wait. Native libraries must flush their
own stdio buffers before returning; writes after the invocation boundary cannot
be captured. Invalid UTF-8 bytes are represented with Unicode replacement characters.

The 8 MiB JSON capture limit applies to native/child output as well. Exceeding it
produces an error envelope rather than a success with silently truncated output.
NDJSON and human output keep their streaming behavior.
10 changes: 8 additions & 2 deletions lib/python/base_cli/_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import tempfile
import traceback
from collections.abc import Callable, Mapping
from contextlib import redirect_stdout
from threading import Lock
from typing import Any, TextIO, cast

Expand All @@ -27,6 +26,7 @@
)
from ._click_compat import dialect_for_command
from ._lifecycle import InvocationOutcome, outcome_from_exception, outcome_from_exit_code, system_exit_code
from ._stdout_capture import StdoutCaptureIncompleteError, capture_stdout
from .exit_codes import ExitCode
from .json_contracts import dumps_envelope, error_envelope, success_envelope
from .lifecycle_options import LifecycleOption, LifecycleOptions
Expand Down Expand Up @@ -301,7 +301,7 @@ def _run_app_invocation(
standalone_mode=False,
)
else:
with redirect_stdout(output_capture):
with capture_stdout(output_capture, _MAX_JSON_CAPTURE_BYTES, JsonCaptureLimitError):
result = command.main(
args=args,
prog_name=display_command or app.name,
Expand Down Expand Up @@ -356,6 +356,12 @@ def _run_app_invocation(
if exc.code is not None and not isinstance(exc.code, int):
print(str(exc.code), file=sys.stderr)
return system_exit_code(exc)
except StdoutCaptureIncompleteError as exc:
if state.json_output:
outcome = InvocationOutcome("capture_incomplete", "error", ExitCode.FAILURE)
_emit_json_error(state, outcome, str(exc), output_capture)
return outcome.exit_code
raise
except JsonCaptureLimitError as exc:
if state.json_output:
outcome = InvocationOutcome("capture_limit", "error", ExitCode.FAILURE)
Expand Down
117 changes: 117 additions & 0 deletions lib/python/base_cli/_stdout_capture.py
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)
Comment thread
codeforester marked this conversation as resolved.
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)
Comment thread
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()
78 changes: 78 additions & 0 deletions tests/test_json_descriptor_capture.py
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"]
Loading