Coming back to this after some discussion with Claude which I kind of forced my ideas into, where you can see the summary from (written by Claude Opus 4.8, edited by me)
The actual root cause
Base.stdout is declared stdout::IO — an abstract binding type. The concrete type isn’t known until __init__, where init_stdio probes fd 1 and returns one of a small closed set: IOStream (file), TTY (terminal), TCPSocket, or PipeEndpoint (pipe), optionally wrapped in IOContext when color is on. Because the declared type is the open ::IO, the trimmer must assume “anything <: IO” and bails as the type is unbounded.
Why Core.stdout works but is not perfect
Core.stdout/CoreSTDOUT is a single fixed type whose tty/file/pipe distinction is pushed into C (jl_uv_puts), so it devirtualizes perfectly and handles pipes fine. But as noted upthread, it means reworking call sites and you lose IOContext features (:color, :displaysize, …). It’s the “collapse to one type” extreme: smallest binary, biggest rework.
The alternative: just narrow the binding type under trim
You don’t need to collapse the hierarchy, and you don’t even need const. A typed global enforces its declared type on every assignment, so if under --trim the binding is declared as the closed union it can actually hold:
stdout::Union{IOStream, TTY, TCPSocket, PipeEndpoint,
IOContext{IOStream}, IOContext{TTY},
IOContext{TCPSocket}, IOContext{PipeEndpoint}}
then inference sees a finite concrete set at every print site and union-splits into a bounded switch. Advantages over const:
redirect_stdout keeps working, as long as the target is in the union (file/pipe/tty/socket — the realistic cases). Only redirect-to-arbitrary-IO (e.g. an IOBuffer) would now throw instead of silently widening.
- It’s a localized change (narrow the binding + give
init_stdio a concrete-union return type), not an IO redesign.
Cost: a constant-factor binary increase, since the write/show stack is retained for ~4 backends instead of 1. The value-type axis stays finite too (your printed types + their transitive show closure), which for a typical CLI is modest.
On the IOContext half
Note that IOContext unqualified is IOContext{T} where T (infinite leaves), so it has to be expanded to the realistic non-nested wraps as above. And whether IOContext is present at all is currently a runtime decision — _reinit_stdio wraps only when --color is set and fd 1 isn’t a TTY. But that decision is central and startup-fixed, not per-call. So the juliac build (which already overrides reinit_stdio) can pin the color policy at build time; commit to “no color” and the IOContext members drop out entirely, halving the union. Which concrete backend you get is an unavoidable runtime probe; whether IOContext is in the union is a build-time policy you control.
TL;DR
A possible fix is narrowing the declared binding type of stdout/stderr to their closed concrete union under --trim. That makes the default sink statically union-splittable, keeps fd/file/pipe redirection working, and costs only a bounded constant-factor in binary size. Pinning the color policy in the trim build removes the IOContext half on top of that.
Base.stdout::Core.CoreSTDOUT vs. the narrowed-union proposal
These are really the same family — “make the declared binding type concrete enough for the trimmer” — but at opposite ends. The key realization is that stdout::CoreSTDOUT is just the Core.stdout outcome reached by retyping the binding instead of rewriting call sites. Since user code already reads the global stdout, retyping the binding is strictly nicer than the original Core.stdout suggestion: you get the single-concrete-type benefit with zero call-site changes. So it dominates the plain Core.stdout proposal.
The interesting comparison is therefore stdout::CoreSTDOUT (collapse to one type) vs. the narrowed union (keep the hierarchy, just bound it).
|
stdout::CoreSTDOUT |
Narrowed union |
| Declared type |
Core.CoreSTDOUT (one concrete singleton) |
Union{IOStream,TTY,TCPSocket,PipeEndpoint,IOContext{…}×4} |
| Call-site rework |
None |
None |
| Binary size |
Smallest — 1 backend column, no switch |
Constant-factor larger — ~4 backend columns, union-split |
IOContext features (:color, :displaysize, :limit) |
Lost — singleton carries none |
Kept |
redirect_stdout |
Impossible — only the singleton inhabits the type |
Works for in-union targets (file/pipe/tty/socket) |
_reinit_stdio under trim |
Trivial — no fd probe, binding stays the boot singleton |
Must run the probe and store a concrete-union result |
| Runtime behavior vs. today |
Changed — degrades to raw byte sink |
Preserved — same concrete types flow as now |
How to read this
stdout::CoreSTDOUT is the floor: the smallest possible binary and the simplest implementation (you don’t even need the startup probe — stdout just stays Core.stdout). The price is that it isn’t behavior-preserving: no color, no displaysize-aware show, no IOContext, and redirect_stdout can’t work at the Julia level because the binding admits exactly one value. Redirection would have to happen at the fd/C layer before launch.
The narrowed union is a superset of fidelity at a bounded size cost. It keeps real TTY/IOContext behavior and keeps Julia-level redirection working, because the binding still admits the full realistic set of backends — it’s just closed instead of open ::IO. You pay for that with a write/show stack retained for ~4 backends instead of 1, plus a union-split branch at each print site.
One important caveat for stdout::CoreSTDOUT: it can only ever be a trim-only retyping. Applying it to normal Julia would regress the interactive experience (REPL color, sizing, rich display all key off the TTY/IOContext types). The narrowed union is also trim-only, but it’s behavior-preserving even where it applies, so it’s far less disruptive if the policy ever leaks beyond trim.
Bottom line
- Want the absolute smallest static binary and can live without color/redirection/rich display →
stdout::CoreSTDOUT. It’s the cleanest spelling of the Core.stdout idea and needs no call-site edits.
- Want the binary small but still behaving like Julia (color,
displaysize, working redirect_stdout) → the narrowed union, optionally dropping the IOContext half by pinning the color policy in the juliac build to shrink toward the CoreSTDOUT floor.
They’re two points on one dial: CoreSTDOUT is “one column,” the narrowed union is “a handful of columns,” and the build-time color policy is how you slide between them.