From eaaec1cbd9716fa1838aea9ace9c1ab6faa60304 Mon Sep 17 00:00:00 2001 From: David Pilar Date: Tue, 19 May 2026 21:22:52 +0200 Subject: [PATCH 1/3] Stop interactive shell runner on context close Resolves #1224 Signed-off-by: David Pilar --- .../shell/core/InteractiveShellRunner.java | 62 ++++++++++++++++++- .../shell/jline/JLineShellRunner.java | 22 +++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/InteractiveShellRunner.java b/spring-shell-core/src/main/java/org/springframework/shell/core/InteractiveShellRunner.java index dda809439..3fe7ab539 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/InteractiveShellRunner.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/InteractiveShellRunner.java @@ -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; @@ -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; @@ -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 @@ -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(); @@ -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; @@ -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(); + } + } diff --git a/spring-shell-jline/src/main/java/org/springframework/shell/jline/JLineShellRunner.java b/spring-shell-jline/src/main/java/org/springframework/shell/jline/JLineShellRunner.java index ae8140841..7420a6929 100644 --- a/spring-shell-jline/src/main/java/org/springframework/shell/jline/JLineShellRunner.java +++ b/spring-shell-jline/src/main/java/org/springframework/shell/jline/JLineShellRunner.java @@ -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; @@ -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; /** @@ -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); + } + } + } + } From 69dfd30782805adceb6372ea49719fdc88db26db Mon Sep 17 00:00:00 2001 From: David Pilar Date: Fri, 29 May 2026 20:13:04 +0200 Subject: [PATCH 2/3] Reuse JLine terminal across context restarts Signed-off-by: David Pilar --- .../JLineShellAutoConfiguration.java | 43 ++++++++++-- .../JLineShellAutoConfigurationTests.java | 66 +++++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 spring-shell-core-autoconfigure/src/test/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfigurationTests.java diff --git a/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java b/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java index cd89028b3..a4039fb72 100644 --- a/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java +++ b/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java @@ -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") @@ -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 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); + } } } diff --git a/spring-shell-core-autoconfigure/src/test/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfigurationTests.java b/spring-shell-core-autoconfigure/src/test/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfigurationTests.java new file mode 100644 index 000000000..f6ddf5c99 --- /dev/null +++ b/spring-shell-core-autoconfigure/src/test/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfigurationTests.java @@ -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 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!"); + } + + } + +} From 4fa4f7f3b1c15e14e0aeaba8c5c41863af2a8cda Mon Sep 17 00:00:00 2001 From: David Pilar Date: Sat, 30 May 2026 10:25:18 +0200 Subject: [PATCH 3/3] Add missing space in error message between command and subcommands Signed-off-by: David Pilar --- .../org/springframework/shell/core/InteractiveShellRunner.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/InteractiveShellRunner.java b/spring-shell-core/src/main/java/org/springframework/shell/core/InteractiveShellRunner.java index 3fe7ab539..469ee0ac3 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/InteractiveShellRunner.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/InteractiveShellRunner.java @@ -136,7 +136,7 @@ private void doRun() { 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();