Skip to content

fix(query): don't query the terminal on handles that are not terminals#713

Open
willemkokke wants to merge 1 commit into
charmbracelet:mainfrom
willemkokke:fix/dont-query-non-terminal-handles
Open

fix(query): don't query the terminal on handles that are not terminals#713
willemkokke wants to merge 1 commit into
charmbracelet:mainfrom
willemkokke:fix/dont-query-non-terminal-handles

Conversation

@willemkokke

Copy link
Copy Markdown

Found while tracking down a start-up hang in tea, the Gitea CLI,
which blocks forever on Windows when its stdio is redirected: https://gitea.com/gitea/tea/issues/1053

The problem

On Windows, BackgroundColor replaces any handle that is not a terminal with CONIN$/CONOUT$
and queries the console instead:

if runtime.GOOS == "windows" {
    if !term.IsTerminal(in.Fd()) {
        f, err := os.OpenFile("CONIN$", os.O_RDWR, 0o644)
        ...
        in = f
    }
    ...
}

So a program whose stdio is redirected — a Windows service, a CI job — writes an OSC 11 query to a
console nobody is watching, and then waits for a reply that never comes. Redirecting stdio does not
protect you, which is surprising: on Unix the same function returns input/output is not a terminal
and gives up.

There is a 2 second timeout on the read, but it does not currently fire, because the CONIN$ handle
this code creates defeats ultraviolet's cancel reader. That is a separate bug and I have opened
charmbracelet/ultraviolet#138 for it. With that fixed, the behaviour here becomes a 2 second stall per call instead of
a hang — better, but still wrong for a program that is only trying to print a version string.

The fix

Do what Unix already does: if the handles the caller passed are not terminals, say so and return.

The caller chose those handles. If output is going to a pipe, the console's background colour is
not the question being asked, and adaptive colour has nothing to adapt to. A caller that genuinely
wants to talk to the console can open CONIN$/CONOUT$ and pass them in — the API takes explicit
handles, so that option is still there.

The Windows branch stays, because console input and output are separate handles and must be queried
as a pair rather than treating either one as bidirectional.

I realise this deletes a deliberate special case, so I am happy to be told the intent was something
I have missed. If the redirected-stdio path needs to stay, the alternative is to keep it and rely on
the ultraviolet fix to bound it — but then every non-interactive Windows program pays a 2 second
stall on start-up, and via the compat package it pays it before main() runs.

Tests

query_test.go, cross-platform, using os.Pipe() handles:

  • BackgroundColor must report that pipes are not terminals, and return rather than wait
  • HasDarkBackground must fall back to its documented dark default

The first fails against main on Windows — it returns no error, because it went and queried the
console anyway. Both pass with the fix. Full suite passes.

Unrelated, but worth flagging

compat runs this query from package-level vars:

var (
    HasDarkBackground = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
    Profile           = colorprofile.Detect(os.Stdout, os.Environ())
)

Importing that package for a type — compat.AdaptiveColor — therefore makes a program query the
terminal before main() runs. That is how tea ended up hanging on tea --version. It cannot be
made lazy without changing the exported bool to a function, so I have not touched it here, but it
seems worth a look.

On Windows, BackgroundColor replaced any handle that was not a terminal with
CONIN$/CONOUT$ and queried the console instead. A program whose stdio is
redirected -- a Windows service, a CI job -- therefore wrote an OSC 11 query to a
console nobody is watching and then waited for a reply that never came.

Unix already returns an error when the handles are not terminals. Do the same on
Windows: the caller passed those handles, so honour them. A caller that really
wants to talk to the console can open CONIN$/CONOUT$ and pass them in, which the
API already allows.

Worth flagging separately: the compat package runs this query from package-level
vars, so it fires before main() for anything that imports it, even to print a
version string.
@willemkokke
willemkokke requested a review from meowgorithm as a code owner July 12, 2026 18:49
@willemkokke
willemkokke changed the base branch from master to main July 12, 2026 19:04
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.

1 participant