Skip to content

Fix #12761: do not wait for the terminal on the thread building it - #12814

Merged
gnodet merged 2 commits into
masterfrom
mng-fasterminal-deadlock
Aug 26, 2026
Merged

Fix #12761: do not wait for the terminal on the thread building it#12814
gnodet merged 2 commits into
masterfrom
mng-fasterminal-deadlock

Conversation

@slachiewicz

@slachiewicz slachiewicz commented Aug 25, 2026

Copy link
Copy Markdown
Member

Fixes #12761. A mvn start can hang before printing anything, with no Java-level deadlock reported.

MessageUtils.systemInstall publishes the FastTerminal immediately and builds the real terminal on fast-terminal-thread. Every FastTerminal method delegates through getTerminal(), which waits on the future that thread completes, so a single log statement from that thread parks it on its own result. main then parks behind it in activateLogging.

The trigger arrived with the rc-6 JLine 3.30.6 → 4.3.1 bump: JLine 4.x initialises its native loader during provider probing and logs a JUL warning when a library candidate fails System.load, by which time activateLogging has bridged JUL to SLF4J. The load failure itself is harmless — JLine recovers by extracting the bundled library.

Two details shaped the fix. The hazard window covers the consumer as well, since consumer.accept(term) runs before terminal.complete(term). And one log statement reaches the terminal twice, through MavenSimpleLogger.renderLeveltoAnsigetType() and again through write → the log sink installed in createTerminalterminal.writer(), so guarding the message builder alone moves the hang rather than removing it.

Those two calls are the whole surface. AttributedCharSequence.toAnsi asks for the type first and renders plain for a dumb terminal, so it never reaches another method. getType() and writer() therefore answer the building thread directly, with TYPE_DUMB and a writer over the stream captured before the consumer swaps the system streams. Rendering degrades to unstyled text while the build is in flight, which is what a dumb terminal would produce anyway.

Two alternatives were tried and rejected. Handing back a stand-in DumbTerminal covers every delegating method at once, but it constructs a terminal inside terminal construction on that same thread, and wedged on ubuntu/jdk-17 (see the second commit). Throwing on re-entry escapes through the JUL handler and aborts JLine's fallback chain, turning a recoverable warning into a failed startup.

Verified: two tests in maven-jline, each exercising both calls from one half of the window. With the guards removed both fail at 30 s; with them they pass in 0.4 s.

maven-4.0.x carries the same code and the same JLine version. The CI symptom is apache/maven-executor#38.

This change was created with AI assistance.

MessageUtils.systemInstall publishes the FastTerminal before the background
thread has built the real one, and every FastTerminal method delegates through
getTerminal(). Anything that thread logs is therefore rendered through a terminal
that same thread is still producing, and parks on its own future. There is no
lock cycle, so jstack reports no deadlock.

Both the builder callable and the consumer callback are inside that window: the
consumer runs before terminal.complete(term). Two distinct call paths reach it
from a single log statement, the level rendering via toAnsi and the log sink via
terminal.writer(), so guarding one call site is not enough. getTerminal() hands
the building thread a dumb stand-in instead, which unblocks every delegating
method at once. Do not narrow this to a plain-text fallback in the message
builder: the writer path would still park.

The stand-in writes to the System.err captured at construction, not the live one,
which the consumer replaces with a logging-backed stream that would feed the
output back into the logger it came from.

Rendering degrades to unstyled text for the duration of the build, which is what
a dumb terminal would produce anyway. The build thread also becomes a daemon, so
a wedged build cannot keep the JVM alive.

JLine 4.x initialises its native loader during provider probing and logs a
warning when a library candidate fails, which is what made this reachable after
the 3.30.6 to 4.3.1 bump in 4.0.0-rc-6. The failure itself is harmless, JLine
recovers by extracting the bundled library.
Handing the building thread a DumbTerminal meant constructing a terminal inside
terminal construction, on that same thread, in a failure path. It wedged on
ubuntu/jdk-17 when the stand-in was built after the consumer had already run
AnsiConsole.systemInstall, while passing everywhere else.

A log statement asks the terminal for exactly two things, its type while
rendering and its writer while emitting. AttributedCharSequence.toAnsi asks for
the type first and renders plain for a dumb one, so it never reaches any other
method. Answering those two with a constant and a PrintWriter over the captured
stream covers the same paths and cannot block.

The tests now exercise both calls from both halves of the window rather than one
call each.

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Well-analyzed fix for a real deadlock (#12761) where the FastTerminal build thread parks on its own CompletableFuture when JLine 4.x logs during terminal construction. The re-entrancy detection is correct, the two guarded methods cover the complete call surface, thread safety is sound, and the tests are thorough.

Highlights:

  • The fix correctly identifies the two terminal methods reachable from a log statement during construction: getType() (via AttributedCharSequence.toAnsi) and writer() (via the log sink installed by AnsiConsole.systemInstall). The analysis of why no other methods need guarding is confirmed by reading JLineMessageBuilderFactory.build() and the JLine toAnsi contract.
  • Thread-safety on fallbackWriter is sound: isBuildThreadWaitingOnItself() returns true only when Thread.currentThread() == buildThread && !terminal.isDone(), so fallbackWriter() is strictly single-thread-accessed. The fallbackOutput field is final and set before Thread.start(), establishing a happens-before guarantee.
  • Making the build thread a daemon is a good safety improvement.
  • The tests are well-designed: assertTimeoutPreemptively uses thread interruption so a regression produces a red test rather than a hung fork.

📋 PR Metadata

Aspect Current Suggested
Milestone (none) 4.0.0-rc-7

🔀 Backport Status

The backport-to-4.0.x label is present — no backport PR found yet, which is expected since this PR was just created. The PR description confirms maven-4.0.x carries the same code and JLine version.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

Claude Code on behalf of Guillaume Nodet

@slachiewicz slachiewicz added this to the 4.0.0-rc-7 milestone Aug 25, 2026
@gnodet
gnodet merged commit 02a675d into master Aug 26, 2026
23 checks passed
@gnodet
gnodet deleted the mng-fasterminal-deadlock branch August 26, 2026 23:23
gnodet added a commit that referenced this pull request Aug 27, 2026
…12814) (#12854)

* Fix #12761: do not wait for the terminal on the thread building it

MessageUtils.systemInstall publishes the FastTerminal before the background
thread has built the real one, and every FastTerminal method delegates through
getTerminal(). Anything that thread logs is therefore rendered through a terminal
that same thread is still producing, and parks on its own future. There is no
lock cycle, so jstack reports no deadlock.

Both the builder callable and the consumer callback are inside that window: the
consumer runs before terminal.complete(term). Two distinct call paths reach it
from a single log statement, the level rendering via toAnsi and the log sink via
terminal.writer(), so guarding one call site is not enough. getTerminal() hands
the building thread a dumb stand-in instead, which unblocks every delegating
method at once. Do not narrow this to a plain-text fallback in the message
builder: the writer path would still park.

The stand-in writes to the System.err captured at construction, not the live one,
which the consumer replaces with a logging-backed stream that would feed the
output back into the logger it came from.

Rendering degrades to unstyled text for the duration of the build, which is what
a dumb terminal would produce anyway. The build thread also becomes a daemon, so
a wedged build cannot keep the JVM alive.

JLine 4.x initialises its native loader during provider probing and logs a
warning when a library candidate fails, which is what made this reachable after
the 3.30.6 to 4.3.1 bump in 4.0.0-rc-6. The failure itself is harmless, JLine
recovers by extracting the bundled library.

* Answer the two terminal calls directly instead of standing in a terminal

Handing the building thread a DumbTerminal meant constructing a terminal inside
terminal construction, on that same thread, in a failure path. It wedged on
ubuntu/jdk-17 when the stand-in was built after the consumer had already run
AnsiConsole.systemInstall, while passing everywhere else.

A log statement asks the terminal for exactly two things, its type while
rendering and its writer while emitting. AttributedCharSequence.toAnsi asks for
the type first and renders plain for a dumb one, so it never reaches any other
method. Answering those two with a constant and a PrintWriter over the captured
stream covers the same paths and cannot block.

The tests now exercise both calls from both halves of the window rather than one
call each.

Co-authored-by: Sylwester Lachiewicz <slachiewicz@apache.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-to-4.0.x bug Something isn't working mvn4

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deadlock when used in maven-executor

2 participants