Skip to content

bug: --json single-envelope stdout contract is broken by any writer to fd 1 #379

Description

@codeforester

Problem

docs/json-contracts.md promises that JSON mode "captures command stdout and emits exactly
one
success or error envelope on stdout". That guarantee only holds for writes that go through
sys.stdout. run_app() installs the capture with contextlib.redirect_stdout()
(lib/python/base_cli/_run.py:236-250), which rebinds the sys.stdout object and does
nothing to file descriptor 1. Anything that writes to fd 1 directly bypasses the capture and
lands on the real stdout, interleaved with the envelope:

  • subprocess.run(...) / Popen with inherited stdout — the normal case for infra tooling that
    shells out to kubectl, terraform, aws, git, ssh;
  • C extensions and native libraries writing to stdout;
  • os.write(1, ...) and sys.__stdout__.

The result is stdout that is not parseable JSON, which is precisely what the strict-JSON
consumer guide (docs/strict-json-consumer.md) tells adopters they can rely on. The limitation
is not documented anywhere.

Verified evidence

Reviewed 2026-09-30 at a58ec109349fa3f3d03eae5b0de078b39ea361a2 (macOS, Python 3.14.6, Click 8.4.2).

A command that prints one structured line and then runs a subprocess, invoked with --json in a
real child process so fd-1 behaviour is faithful:

@app.command()
def main(ctx):
    print('{"records": [{"host": "web-1", "status": "ok"}]}')
    subprocess.run([sys.executable, "-c", "print('SUBPROCESS-DIRECT-TO-FD1')"], check=True)
    return 0

Raw stdout of the CLI process:

SUBPROCESS-DIRECT-TO-FD1
{"schema_version":1,"schema":"base-cli.output","code":"ok",...,"details":{"exit_code":0,"stdout":"{\"records\": ...

json.loads(stdout) fails: Expecting value: line 1 column 1 (char 0). The subprocess line
appears before the envelope, so even a "parse the last line" workaround is fragile once a
subprocess emits multiple lines or no trailing newline.

Proposal

Pick one and document it as normative:

  1. Redirect the descriptor. In JSON mode, dup2() a pipe or the spool file over fd 1 for the
    duration of the invocation and restore it before writing the envelope. This captures
    subprocesses and native writers, at the cost of real descriptor plumbing and a Windows path.
  2. Declare and detect the boundary. Keep redirect_stdout, document that fd-1 writers are
    outside the contract, and give commands a supported way to route child output (for example a
    ctx.stdout the lifecycle owns, plus guidance to pass stdout= explicitly to subprocess).
    Optionally detect a dirtied fd 1 and downgrade to a documented error envelope rather than
    emitting invalid stdout.

Option 1 keeps the published guarantee true; option 2 changes the guarantee. Either is
defensible, but the current state promises option 1 and implements option 2 without saying so.

Acceptance criteria

  • docs/json-contracts.md and docs/strict-json-consumer.md state exactly what JSON mode does
    and does not capture, with the subprocess case named explicitly.
  • A regression test runs a real subprocess inside a --json invocation and asserts the whole of
    stdout parses as a single JSON document (option 1) or that the documented failure mode occurs
    deterministically (option 2).
  • Consumer guidance shows the supported way to run a child process under JSON mode.

Non-goals

  • Do not silently truncate or discard child-process output.
  • Do not make NDJSON mode capture stdout; it intentionally streams.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area: runtimeRuntime, lifecycle, execution, or process-boundary ownership.bugSomething is not working

Type

No type

Projects

  • Status
    Backlog

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions