Skip to content

Conversation

@asmacdo
Copy link
Member

@asmacdo asmacdo commented Jan 16, 2026

Summary

This PR reintroduces the PTY-based TeeStream class (which was replaced by TailPipe in May 2024) to investigate whether it can help with stdout/stderr interleaving for #313.

Findings

The buffering problem

When capturing output through duct, interleaving is lost even when the child process outputs in order:

# Expected (what child writes):
stdout 0, stderr 0, stdout 1, stderr 1, ...

# Actual (what duct shows):
stdout 0-19, then stderr 0-19

What we tested

Approach Without duct Through duct
Base case ❌ batched ❌ batched
PYTHONUNBUFFERED=1 ✅ interleaved ❌ still batched
python -u ✅ interleaved ❌ still batched
stdbuf -oL ✅ interleaved ❌ still batched

Key insight: The child's buffering isn't the problem - duct's architecture is.

Why TailPipe doesn't interleave

  1. Popen(stdout=file1, stderr=file2) - streams separated at kernel level
  2. Two independent TailPipe threads read from separate files
  3. Thread scheduling determines display order, not write order

Why TeeStream (PTY) also doesn't interleave (in current form)

We reintroduced TeeStream with PTY-based capture, but with two separate PTYs (one per stream), interleaving is still lost.

What DOES work

When both stdout AND stderr go to the same PTY slave fd:

master_fd, slave_fd = os.openpty()
proc = subprocess.Popen(
    ["python", "interleave.py"],
    stdout=slave_fd,
    stderr=slave_fd,  # Both to same PTY!
)
# Read from master_fd -> perfect interleaving!

This gives perfect interleaving because the kernel merges both streams in arrival order.

Recommendation for #313

For a --capture-outputs=combined or annotated mode:

  • Use a single shared PTY for both stdout and stderr
  • Read from the PTY master and write to a combined output file
  • Optionally annotate with [stdout]/[stderr] prefixes (though with shared PTY, we lose stream distinction)

If stream distinction is needed with interleaving, would need the pipe+select approach:

  • Use subprocess.PIPE for both streams
  • Use select.select() to multiplex reads in a single thread
  • Annotate each chunk with its source stream

Test script

interleave.py is included for reproducing the behavior.


🤖 Generated with Claude Code

@asmacdo
Copy link
Member Author

asmacdo commented Jan 16, 2026

@candleindark this is quickly-generated AI outputs, so its not fully trustworthy, but it aligns with my understanding & memory of how things once were. Hope this is helpful, I'm down to pair on this too if you like.

Adds TeeStream class back alongside TailPipe to explore PTY-based
output capture for better interleaving support.

Findings:
- PTY approach gives line-buffered output from child processes
- However, two separate PTYs (one per stream) still don't interleave
- True interleaving requires BOTH stdout and stderr to same PTY slave fd
- This would be a new "combined" capture mode, not a drop-in replacement

Test script interleave.py included for reproducing the behavior.

See #313 for discussion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
actions-user and others added 2 commits January 16, 2026 19:36
=== Do not change lines below ===
{
 "chain": [],
 "cmd": "pre-commit install && pre-commit run --all-files || true && ./.update-readme-help.py",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [
  "."
 ],
 "pwd": "."
}
^^^ Do not change lines above ^^^
- Replace master/slave terminology with pty_read_fd/pty_subprocess_fd
- Add ASCII diagram showing PTY data flow
- Document that separate TeeStreams don't achieve interleaving

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants