Description
processManager in ReadlineConsole is a plain (non-volatile) field initialized in accept() on the aesh-test-repl thread, but read by the aesh-test-reader thread in processLine(). Under CI load, the reader thread can see processManager as null, causing an NPE.
This is related to #570, which fixed the ordering within accept() (close handler was set before processManager was initialized). The current issue is a cross-thread visibility problem — the reader thread started by openBlocking() → startReader() may not see the processManager write from the thread that called accept().
Stack trace (from Quarkus aesh extension CI)
java.lang.NullPointerException: Cannot invoke "org.aesh.console.ProcessManager.execute(
org.aesh.command.Executor, org.aesh.terminal.Connection, String)"
because "this.processManager" is null
at org.aesh.console.ReadlineConsole.processLine(ReadlineConsole.java:431)
at org.aesh.console.ReadlineConsole.lambda$read$3(ReadlineConsole.java:330)
at org.aesh.readline.Readline$AeshInputProcessor.finish(Readline.java:337)
at org.aesh.readline.Readline$AeshInputProcessor.parse(Readline.java:367)
at org.aesh.readline.Readline$AeshInputProcessor.access$100(Readline.java:267)
at org.aesh.readline.Readline.readInput(Readline.java:153)
at org.aesh.readline.Readline.access$1100(Readline.java:61)
at org.aesh.readline.Readline$AeshInputProcessor.lambda$start$3(Readline.java:532)
at io.quarkus.aesh.runtime.AeshStreamConnection.lambda$startReader$1(AeshStreamConnection.java:193)
at java.base/java.lang.Thread.run(Thread.java:1474)
Reproduction
Intermittent under CI load. The sequence is:
-
accept() runs on the aesh-test-repl thread:
- Sets
processManager = new ProcessManager(this) (plain field write)
- Calls
read() which arms readline and draws prompt to output
- Calls
connection.openBlocking() which starts the aesh-test-reader thread
-
The test framework sees the prompt and writes command bytes into the pipe before openBlocking() starts the reader thread. The bytes buffer in PipedInputStream.
-
When startReader() creates and starts the reader thread, it reads the buffered bytes immediately and processes them through readline → processLine() → accesses processManager.
-
While Thread.start() should provide a happens-before edge, under heavy CI load the reader thread may see a stale value for processManager (null).
The NPE is caught by the catch (Exception e) block in processLine(), which logs it and calls read() for a new readline cycle. But the command was already consumed — the test framework never receives the completion signal, resulting in a 30-second timeout.
Suggested fix
Move processManager initialization from accept() to the constructor. It only depends on this (the ReadlineConsole instance), not on the Connection:
public ReadlineConsole(Settings<? extends CommandInvocation> givenSettings) {
// ... existing constructor code ...
processManager = new ProcessManager(this);
}
Then in accept(), just set the execution listener:
processManager.setExecutionListener(settings.commandExecutionListener());
This eliminates the cross-thread visibility concern entirely since the field is set during construction, before the object reference is ever published to other threads.
Alternatively, making processManager volatile would also fix the visibility issue:
private volatile ProcessManager processManager;
Environment
- aesh 3.17-dev
- Java 25 (Temurin)
- Quarkus 3.38.1
- GitHub Actions CI runner (Ubuntu)
- Observed in PostgreSQL test matrix job (higher startup overhead than SQLite)
Description
processManagerinReadlineConsoleis a plain (non-volatile) field initialized inaccept()on theaesh-test-replthread, but read by theaesh-test-readerthread inprocessLine(). Under CI load, the reader thread can seeprocessManagerasnull, causing an NPE.This is related to #570, which fixed the ordering within
accept()(close handler was set beforeprocessManagerwas initialized). The current issue is a cross-thread visibility problem — the reader thread started byopenBlocking()→startReader()may not see theprocessManagerwrite from the thread that calledaccept().Stack trace (from Quarkus aesh extension CI)
Reproduction
Intermittent under CI load. The sequence is:
accept()runs on theaesh-test-replthread:processManager = new ProcessManager(this)(plain field write)read()which arms readline and draws prompt to outputconnection.openBlocking()which starts theaesh-test-readerthreadThe test framework sees the prompt and writes command bytes into the pipe before
openBlocking()starts the reader thread. The bytes buffer inPipedInputStream.When
startReader()creates and starts the reader thread, it reads the buffered bytes immediately and processes them through readline →processLine()→ accessesprocessManager.While
Thread.start()should provide a happens-before edge, under heavy CI load the reader thread may see a stale value forprocessManager(null).The NPE is caught by the
catch (Exception e)block inprocessLine(), which logs it and callsread()for a new readline cycle. But the command was already consumed — the test framework never receives the completion signal, resulting in a 30-second timeout.Suggested fix
Move
processManagerinitialization fromaccept()to the constructor. It only depends onthis(theReadlineConsoleinstance), not on theConnection:Then in
accept(), just set the execution listener:This eliminates the cross-thread visibility concern entirely since the field is set during construction, before the object reference is ever published to other threads.
Alternatively, making
processManagervolatile would also fix the visibility issue:Environment