Skip to content

Tty conversion - #126

Draft
graeme wants to merge 20 commits into
mainfrom
tty-conversion
Draft

Tty conversion#126
graeme wants to merge 20 commits into
mainfrom
tty-conversion

Conversation

@graeme

@graeme graeme commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

PR: Run brew under a pseudo-terminal

Summary

Homebrew commands shown in the console now run against a pseudo-terminal instead of pipes. Brew and the tools it shells out to see a real terminal, so they emit their own colour and progress rendering, and output arrives as it is produced rather than in block-buffered bursts. Commands whose output gets parsed stay on pipes, so no parser changes behaviour.

Changes

  • Adds swift-subprocess, pinned at 1.0.0 and scoped to BrewCLI. A controlling terminal needs setsid() between fork and exec, which Foundation.Process cannot express.
  • Adds PseudoTerminal, wrapping openpty with termios and window-size setup, descriptor ownership and a bounded read.
  • Ports BrewCommandService to Subprocess. Runs create their own session, so teardown signals the whole process group and cancelling an install stops the curl or git it was waiting on.
  • Adds TerminalLineAssembler, applying terminal overwrite semantics so a redrawing progress bar settles into one console row instead of several hundred.
  • Console lines carry resolved styled spans and a completeness flag; a job revises its trailing row rather than appending.
  • Display-mode runs use the pty and return the assembled transcript, which is also what a failed run reports as its diagnostic. Capture-mode runs stay on pipes, where the streams remain distinct.
  • Falls back to pipes if allocation fails. Decoding is lossy, so one bad byte costs a character rather than a line or a run.

Why this split

Screen-level emulation, which the multi-line spinner artifacts need, is a different data model: a grid with a cursor rather than a transcript of rows. It would touch scrollback, selection, copy and export, so it belongs in its own PR.

Testing

  • scripts/test, 757 package tests plus 19 BrewUILint tests
  • swiftformat, swiftlint --strict, BrewUILint over the tree
  • xcodebuild -scheme Brew, signing disabled
  • Suite run 8 times consecutively for the pty flakiness fix
  • Real curl and brew fetch through the production path
  • Manual: watch a real brew upgrade in the app console

PR checklist

  • Have you followed this repository's contribution and workflow guidance?
  • Have you explained what changed and why this should land now?
  • Have you run relevant local checks for the changed scope?
  • Are changes scoped and free of unrelated modifications?

  • AI was used to generate or assist with generating this PR.
  • Claude Code wrote the implementation, tests and description. Teardown, timing and allocation limits are measured, not assumed. A review pass caught a regression where a failed install reported a bare exit code, because a terminal merges the streams and the failure path read only stderr; that and eight smaller findings were fixed and covered. Console animation is the one unmeasured claim, as the GUI could not be run here.

Follow-ups

  • Multi-line spinner redraws leave artifacts, such as a leftover prefix on a rewritten line. Needs the screen buffer above.
  • Sizing the pty to the console width, and SIGWINCH on resize.
  • Console rows wrap. A terminal does not reflow, so horizontal scrolling would be more faithful.

graeme and others added 20 commits August 12, 2026 22:53
Foundation.Process cannot call setsid() between fork and exec, which a
controlling-terminal pty requires. Subprocess exposes it as
PlatformOptions.createSession, alongside the file-descriptor hand-off and
process-group teardown the pty path needs.

Pinned exactly at 1.0.0 per CONVENTIONS.md; pulls in swift-system 1.8.0
transitively. Scoped to the BrewCLI target only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wraps openpty with the two things that are easy to get wrong: the local
replica descriptor must be closed after spawn or reads never see EOF, and
end-of-input arrives as either 0 or EIO depending on ordering.

Clears OPOST on the replica so the kernel does not rewrite every \n as
\r\n. Carriage returns the child writes itself are untouched, which is what
keeps progress-bar redraws intact while leaving captured bytes verbatim.

Sets a non-zero window size; openpty defaults to 0x0, which some tools read
as "not a terminal" and use to suppress progress output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selects terminal-backed execution per run rather than globally, so runs
whose output is parsed can stay on pipes where stdout and stderr remain
distinct. Defaults to false; no caller opts in yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both execution paths now go through Subprocess rather than Foundation.Process.
The pipe path is behaviour-preserving; the new terminal path hands the child a
pty replica as stdout and stderr, so isatty holds and Homebrew emits colour and
progress rendering without HOMEBREW_COLOR forcing it.

Runs create their own session. That is what makes the pty a controlling
terminal rather than just a terminal-shaped descriptor, and it puts the child
and its descendants in one process group so teardown signals all of them —
cancelling an install now stops the curl or git it was waiting on instead of
orphaning it. Verified by test: two processes spawned, none left after cancel.

The terminal drain stops on a quiet terminal once the child has exited, rather
than waiting for end-of-input. End-of-input is driven by descriptors, so a
grandchild holding the replica would otherwise stall the drain indefinitely.

TERM is set explicitly because a GUI process launched from Finder inherits
none, and tools suppress colour and progress without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Display work is only ever shown, so it takes the terminal path and gets
Homebrew's colour and progress rendering natively. Capture work is parsed by
its caller and stays on pipes, where stdout and stderr remain distinct — it
keeps forcing colour, since a pipe strips it and brew doctor is both shown
and parsed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Standard input stays on /dev/null even when stdout and stderr are a
terminal. An interactive rc file that reads stdin would otherwise block
forever, since nothing ever writes to the pty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picks up swift-subprocess 1.0.0 and its swift-system dependency, matching
the package-level Package.resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scanner only told callers whether a sequence was SGR. Reporting the
final byte alongside the parameters lets a second caller act on erase-in-line
and friends without duplicating the scanning.

Pure refactor: parse still acts only on 'm'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies terminal overwrite semantics to a stream of output. A terminal is
not an append-only transcript: a carriage return moves the cursor back to
column zero and what follows overwrites what was there. curl redraws its
progress bar hundreds of times that way, with no newline between redraws.

Splitting on newlines alone turns a whole download into one enormous line
holding every intermediate state. This keeps the line as cells with a
cursor, so a redraw replaces earlier content instead of accumulating after
it, and reports both committed lines and in-progress revisions.

Styling is tracked per cell rather than left inline, because counting escape
bytes as visible columns would land every later overwrite in the wrong place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A terminal-backed line is not final when it first appears: a progress bar
revises the same line repeatedly before a newline settles it. Lines now
carry whether they are complete, plus their resolved styled spans, and a
job replaces its trailing incomplete row rather than appending.

The replacement keeps the row's identity so anything keyed on it sees one
row changing rather than a new row each redraw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Terminal output no longer splits on newlines, because a terminal's redraws
are separated by carriage returns — by the newline measure a whole download
is one line. The assembler resolves the overwrites and reports the line
while it is still being drawn, so a progress bar animates in place instead
of leaving hundreds of rows behind.

Reads land on arbitrary byte boundaries, so a multi-byte character split
across two reads is held until the rest arrives rather than dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lines arrive with their styling already resolved, so the console renders
those spans rather than re-parsing the raw text on every pass. For
terminal-backed runs the spans also reflect the overwrites the assembler
applied, which raw text alone cannot express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spans the boundary the bug actually crossed: a real subprocess writing 600
carriage-return redraws, through the drain and assembler, into the console's
job buffer. Asserts the four rows a terminal would show rather than the
several hundred the console used to accumulate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Building the message allocates, and an allocation makes syscalls of its own
that overwrite errno before strerror reads it. The failure reported as
"Unknown error: -6"; captured immediately it is ENXIO, which is what
actually points at pty exhaustion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pty pool is far smaller than kern.tty.ptmx_max advertises — measured at
~36 concurrent on a development machine. Swift Testing runs suites in
parallel, so these tests were competing for it and failing with ENXIO in
3 of 8 runs.

Allocation itself is sound: 700 sequential allocate-and-release cycles pass
without a failure, so nothing leaks. Nothing in the app contends this way
either, since the command center runs one subprocess at a time. Ten
consecutive full-suite runs now pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Allocation is an environmental resource that genuinely runs out — the device
pool is much smaller than kern.tty.ptmx_max advertises. Failing an install
over it is a poor trade when the command runs perfectly well on pipes; what
degrades is Homebrew's progress rendering, not the operation.

The fallback forces colour back on, since the terminal was what supplied it,
and keeps the caller's line observer so the console still streams. Only
allocation failure degrades: a command that cannot run still fails.

Reached through an injected factory, since the failure is otherwise only
reachable by exhausting the machine's pty devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drops restatements of what the code already says, keeping the notes that
document something non-obvious: descriptor ownership, errno capture, OPOST,
the drain's timeout, setsid, and the pty pool's size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ESC[G, ESC[C and ESC[D address a column within the current line, so they
belong to the line model rather than to the screen-buffer follow-up. Some
progress renderers use ESC[G where curl uses \r; ignoring it let a redraw
append instead of overwrite, which is the exact failure the assembler
exists to prevent.

Columns are clamped: a malformed ESC[999999999C would otherwise have the
next write pad every cell up to it.

Erase-to-start now blanks with the current style. A terminal erases with
the active attribute, so carrying the old one over left a stale colour
behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A terminal merges stdout and stderr onto one device, so a pty run returns
an empty standardError. SerialBrewCommandCenter read exactly that field to
build BrewCommandError.failed for display work, and display work is the
only path that runs on a pty. Every failed install, upgrade and uninstall
therefore reached the user as OperationFailure's generic fallback string
instead of brew's own message.

The merged transcript is the only account of the failure on that path, so
CommandFailureDetail prefers stderr and falls back to the transcript's
tail. Trimmed, because it feeds an error banner rather than a log pane.

To make that fallback usable, the pty path now returns the assembled
transcript rather than the verbatim bytes. Raw pty bytes are a cursor
script in which a whole download is one line carrying every intermediate
redraw; resolving the overwrites is what makes the text readable. Nothing
parses display output, and the drain already had the assembled lines, so
this also gives a purpose to bytes that were previously accumulated for
the life of the run and never read.

Decoding is no longer all-or-nothing. takeDecodableUTF8Prefix held back
only trailing bytes, so an invalid byte anywhere else made every candidate
fail and cost a *leading* byte per read instead — at one byte discarded
per 4096 arriving, output that produced invalid bytes faster than that
grew the buffer without bound and silenced the console for the rest of the
run. UTF8StreamDecoder holds back only a genuinely incomplete trailing
sequence and substitutes U+FFFD for the rest. The same reasoning replaces
the failable String initialisers, where one bad byte discarded a whole
run's output, or a whole line of it.

BrewRunOptions carried the channel as two booleans that could contradict
each other, with forceColor documented as redundant against the other.
They are one three-state choice, so they are now one enum, and the
merged-streams contract lives in the type rather than a doc comment.

PseudoTerminal.read no longer reports a bad descriptor as a clean finish;
a truncated run says so instead of passing for a complete one. Its
descriptors are let-bound, since they never change after init, and the
ordering rule the unlocked read actually depends on is documented on
closePrimary rather than implied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

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.

2 participants