Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ public class JLineShellAutoConfiguration {

private final static Log log = LogFactory.getLog(JLineShellAutoConfiguration.class);

// Reused across context restarts (e.g. DevTools) to keep one reader of stdin.
private static volatile Terminal sharedTerminal;

private static final Object sharedTerminalLock = new Object();

private org.jline.reader.History jLineHistory;

@Value("${spring.application.name:spring-shell}.log")
Expand Down Expand Up @@ -158,16 +163,40 @@ public JLineInputProvider inputProvider(LineReader lineReader, PromptProvider pr
return inputProvider;
}

@Bean(destroyMethod = "close")
// Not closed on context close (shared); closed on JVM shutdown instead.
@Bean(destroyMethod = "")
public Terminal terminal(ObjectProvider<TerminalCustomizer> customizers) {
Terminal terminal = sharedTerminal;
if (terminal != null) {
return terminal;
}
synchronized (sharedTerminalLock) {
if (sharedTerminal == null) {
try {
TerminalBuilder builder = TerminalBuilder.builder();
builder.systemOutput(SystemOutput.SysOut);
customizers.orderedStream().forEach(customizer -> customizer.customize(builder));
Terminal built = builder.build();
Runtime.getRuntime()
.addShutdownHook(new Thread(() -> closeTerminal(built), "spring-shell-terminal-close"));
sharedTerminal = built;
}
catch (IOException e) {
throw new BeanCreationException("Could not create Terminal", e);
}
}
return sharedTerminal;
}
}

private static void closeTerminal(Terminal terminal) {
try {
TerminalBuilder builder = TerminalBuilder.builder();
builder.systemOutput(SystemOutput.SysOut);
customizers.orderedStream().forEach(customizer -> customizer.customize(builder));
return builder.build();
terminal.close();
}
catch (IOException e) {
throw new BeanCreationException("Could not create Terminal", e);
catch (IOException ex) {
if (log.isDebugEnabled()) {
log.debug("Failed to close terminal on shutdown", ex);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.core.autoconfigure;

import java.util.concurrent.atomic.AtomicReference;

import org.jline.terminal.Terminal;
import org.junit.jupiter.api.Test;

import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.shell.core.command.annotation.Command;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertSame;

/**
* @author David Pilar
*/
class JLineShellAutoConfigurationTests {

private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(SpringShellApplication.class)
.withConfiguration(AutoConfigurations.of(SpringShellAutoConfiguration.class))
.withPropertyValues("spring.shell.interactive.enabled=false");

@Test
void terminalIsReusedAcrossContextsAndSurvivesContextClose() {
AtomicReference<Terminal> firstTerminal = new AtomicReference<>();
this.contextRunner.run(context -> firstTerminal.set(context.getBean(Terminal.class)));

// First context is closed; the shared terminal must survive so a restart reuses
// it.
this.contextRunner.run(context -> {
Terminal terminal = context.getBean(Terminal.class);
assertSame(firstTerminal.get(), terminal);
// getAttributes() throws if the terminal has been closed
assertDoesNotThrow(terminal::getAttributes);
});
}

@SpringBootApplication
static class SpringShellApplication {

@Command
void hi() {
System.out.println("Hello world!");
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.beans.factory.DisposableBean;
import org.springframework.shell.core.command.CommandContext;
import org.springframework.shell.core.command.CommandExecutionException;
import org.springframework.shell.core.command.CommandExecutor;
Expand All @@ -35,12 +36,15 @@
* to print messages and flush the output.
*
* @author Mahmoud Ben Hassine
* @author David Pilar
* @since 4.0.0
*/
public abstract class InteractiveShellRunner implements ShellRunner {
public abstract class InteractiveShellRunner implements ShellRunner, DisposableBean {

private static final Log log = LogFactory.getLog(InteractiveShellRunner.class);

private static final long STOP_JOIN_TIMEOUT_MS = 2000L;

private final CommandParser commandParser;

private final CommandExecutor commandExecutor;
Expand All @@ -51,6 +55,10 @@ public abstract class InteractiveShellRunner implements ShellRunner {

private boolean debugMode = false;

private volatile boolean running = true;

private volatile Thread runnerThread;

/**
* Create a new {@link InteractiveShellRunner} instance.
* @param inputProvider the input provider
Expand All @@ -70,7 +78,17 @@ public void run(String[] args) throws Exception {
if (args.length != 0) {
log.warn("Running in interactive mode, arguments will be ignored");
}
while (true) {
this.runnerThread = Thread.currentThread();
try {
doRun();
}
finally {
this.runnerThread = null;
}
}

private void doRun() {
while (this.running) {
String input;
try {
input = this.inputProvider.readInput();
Expand All @@ -84,7 +102,7 @@ public void run(String[] args) throws Exception {
}
}
catch (Exception e) {
if (this.debugMode) {
if (this.running && this.debugMode) {
e.printStackTrace();
}
break;
Expand Down Expand Up @@ -118,7 +136,7 @@ public void run(String[] args) throws Exception {
while (cause != null && cause.getCause() != null) {
cause = cause.getCause();
}
String errorMessage = "Unable to run command " + parsedInput.commandName()
String errorMessage = "Unable to run command " + parsedInput.commandName() + " "
+ String.join(" ", parsedInput.subCommands());
if (cause != null && cause.getMessage() != null) {
errorMessage += ": " + cause.getMessage();
Expand Down Expand Up @@ -169,4 +187,42 @@ public void setDebugMode(boolean debugMode) {
this.debugMode = debugMode;
}

/**
* Signal the runner to stop and wait briefly for its thread to exit.
*/
public void stop() {
if (!this.running) {
return;
}
this.running = false;
try {
wakeup();
}
catch (Exception ex) {
if (log.isDebugEnabled()) {
log.debug("Failed to wake up shell runner", ex);
}
}
Thread thread = this.runnerThread;
if (thread != null && thread != Thread.currentThread()) {
try {
thread.join(STOP_JOIN_TIMEOUT_MS);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}

/**
* Unblock a parked {@link InputProvider#readInput()} so {@link #stop()} can return.
*/
protected void wakeup() {
}

@Override
public void destroy() {
stop();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
import java.io.Console;
import java.io.PrintWriter;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jline.reader.LineReader;
import org.jline.terminal.Terminal;

import org.springframework.shell.core.InputReader;
import org.springframework.shell.core.InteractiveShellRunner;
Expand All @@ -29,10 +32,13 @@
* Interactive shell runner based on the JVM's system {@link Console}.
*
* @author Mahmoud Ben Hassine
* @author David Pilar
* @since 4.0.0
*/
public class JLineShellRunner extends InteractiveShellRunner {

private static final Log log = LogFactory.getLog(JLineShellRunner.class);

private final LineReader lineReader;

/**
Expand Down Expand Up @@ -67,4 +73,20 @@ public InputReader getReader() {
return new JLineInputReader(this.lineReader);
}

/**
* Raise {@code INT} on the terminal so the blocked {@link LineReader#readLine()}
* throws.
*/
@Override
protected void wakeup() {
try {
this.lineReader.getTerminal().raise(Terminal.Signal.INT);
}
catch (Exception ex) {
if (log.isDebugEnabled()) {
log.debug("Failed to raise INT on terminal to wake up line reader", ex);
}
}
}

}
Loading