Skip to content

ReadlineConsole: processManager visibility race in processLine() across threads #580

Description

@stalep

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:

  1. 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
  2. The test framework sees the prompt and writes command bytes into the pipe before openBlocking() starts the reader thread. The bytes buffer in PipedInputStream.

  3. When startReader() creates and starts the reader thread, it reads the buffered bytes immediately and processes them through readline → processLine() → accesses processManager.

  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions