Skip to content

Commit 7cfedb2

Browse files
czpilarfmbenhassine
authored andcommitted
Stop interactive shell runner on context close
Reuse JLine terminal across context restarts Resolves #1224 Signed-off-by: David Pilar <david@czpilar.net>
1 parent eb7498c commit 7cfedb2

4 files changed

Lines changed: 183 additions & 10 deletions

File tree

spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/JLineShellAutoConfiguration.java

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ public class JLineShellAutoConfiguration {
7979

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

82+
// Reused across context restarts (e.g. DevTools) to keep one reader of stdin.
83+
private static volatile Terminal sharedTerminal;
84+
85+
private static final Object sharedTerminalLock = new Object();
86+
8287
private org.jline.reader.History jLineHistory;
8388

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

161-
@Bean(destroyMethod = "close")
166+
// Not closed on context close (shared); closed on JVM shutdown instead.
167+
@Bean(destroyMethod = "")
162168
public Terminal terminal(ObjectProvider<TerminalCustomizer> customizers) {
169+
Terminal terminal = sharedTerminal;
170+
if (terminal != null) {
171+
return terminal;
172+
}
173+
synchronized (sharedTerminalLock) {
174+
if (sharedTerminal == null) {
175+
try {
176+
TerminalBuilder builder = TerminalBuilder.builder();
177+
builder.systemOutput(SystemOutput.SysOut);
178+
customizers.orderedStream().forEach(customizer -> customizer.customize(builder));
179+
Terminal built = builder.build();
180+
Runtime.getRuntime()
181+
.addShutdownHook(new Thread(() -> closeTerminal(built), "spring-shell-terminal-close"));
182+
sharedTerminal = built;
183+
}
184+
catch (IOException e) {
185+
throw new BeanCreationException("Could not create Terminal", e);
186+
}
187+
}
188+
return sharedTerminal;
189+
}
190+
}
191+
192+
private static void closeTerminal(Terminal terminal) {
163193
try {
164-
TerminalBuilder builder = TerminalBuilder.builder();
165-
builder.systemOutput(SystemOutput.SysOut);
166-
customizers.orderedStream().forEach(customizer -> customizer.customize(builder));
167-
return builder.build();
194+
terminal.close();
168195
}
169-
catch (IOException e) {
170-
throw new BeanCreationException("Could not create Terminal", e);
196+
catch (IOException ex) {
197+
if (log.isDebugEnabled()) {
198+
log.debug("Failed to close terminal on shutdown", ex);
199+
}
171200
}
172201
}
173202

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright 2025-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.shell.core.autoconfigure;
17+
18+
import java.util.concurrent.atomic.AtomicReference;
19+
20+
import org.jline.terminal.Terminal;
21+
import org.junit.jupiter.api.Test;
22+
23+
import org.springframework.boot.autoconfigure.AutoConfigurations;
24+
import org.springframework.boot.autoconfigure.SpringBootApplication;
25+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
26+
import org.springframework.shell.core.command.annotation.Command;
27+
28+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
29+
import static org.junit.jupiter.api.Assertions.assertSame;
30+
31+
/**
32+
* @author David Pilar
33+
*/
34+
class JLineShellAutoConfigurationTests {
35+
36+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
37+
.withUserConfiguration(SpringShellApplication.class)
38+
.withConfiguration(AutoConfigurations.of(SpringShellAutoConfiguration.class))
39+
.withPropertyValues("spring.shell.interactive.enabled=false");
40+
41+
@Test
42+
void terminalIsReusedAcrossContextsAndSurvivesContextClose() {
43+
AtomicReference<Terminal> firstTerminal = new AtomicReference<>();
44+
this.contextRunner.run(context -> firstTerminal.set(context.getBean(Terminal.class)));
45+
46+
// First context is closed; the shared terminal must survive so a restart reuses
47+
// it.
48+
this.contextRunner.run(context -> {
49+
Terminal terminal = context.getBean(Terminal.class);
50+
assertSame(firstTerminal.get(), terminal);
51+
// getAttributes() throws if the terminal has been closed
52+
assertDoesNotThrow(terminal::getAttributes);
53+
});
54+
}
55+
56+
@SpringBootApplication
57+
static class SpringShellApplication {
58+
59+
@Command
60+
void hi() {
61+
System.out.println("Hello world!");
62+
}
63+
64+
}
65+
66+
}

spring-shell-core/src/main/java/org/springframework/shell/core/InteractiveShellRunner.java

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.apache.commons.logging.Log;
2121
import org.apache.commons.logging.LogFactory;
2222

23+
import org.springframework.beans.factory.DisposableBean;
2324
import org.springframework.shell.core.command.CommandContext;
2425
import org.springframework.shell.core.command.CommandExecutionException;
2526
import org.springframework.shell.core.command.CommandExecutor;
@@ -35,12 +36,15 @@
3536
* to print messages and flush the output.
3637
*
3738
* @author Mahmoud Ben Hassine
39+
* @author David Pilar
3840
* @since 4.0.0
3941
*/
40-
public abstract class InteractiveShellRunner implements ShellRunner {
42+
public abstract class InteractiveShellRunner implements ShellRunner, DisposableBean {
4143

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

46+
private static final long STOP_JOIN_TIMEOUT_MS = 2000L;
47+
4448
private final CommandParser commandParser;
4549

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

5256
private boolean debugMode = false;
5357

58+
private volatile boolean running = true;
59+
60+
private volatile Thread runnerThread;
61+
5462
/**
5563
* Create a new {@link InteractiveShellRunner} instance.
5664
* @param inputProvider the input provider
@@ -70,7 +78,17 @@ public void run(String[] args) throws Exception {
7078
if (args.length != 0) {
7179
log.warn("Running in interactive mode, arguments will be ignored");
7280
}
73-
while (true) {
81+
this.runnerThread = Thread.currentThread();
82+
try {
83+
doRun();
84+
}
85+
finally {
86+
this.runnerThread = null;
87+
}
88+
}
89+
90+
private void doRun() {
91+
while (this.running) {
7492
String input;
7593
try {
7694
input = this.inputProvider.readInput();
@@ -84,7 +102,7 @@ public void run(String[] args) throws Exception {
84102
}
85103
}
86104
catch (Exception e) {
87-
if (this.debugMode) {
105+
if (this.running && this.debugMode) {
88106
e.printStackTrace();
89107
}
90108
break;
@@ -169,4 +187,42 @@ public void setDebugMode(boolean debugMode) {
169187
this.debugMode = debugMode;
170188
}
171189

190+
/**
191+
* Signal the runner to stop and wait briefly for its thread to exit.
192+
*/
193+
public void stop() {
194+
if (!this.running) {
195+
return;
196+
}
197+
this.running = false;
198+
try {
199+
wakeup();
200+
}
201+
catch (Exception ex) {
202+
if (log.isDebugEnabled()) {
203+
log.debug("Failed to wake up shell runner", ex);
204+
}
205+
}
206+
Thread thread = this.runnerThread;
207+
if (thread != null && thread != Thread.currentThread()) {
208+
try {
209+
thread.join(STOP_JOIN_TIMEOUT_MS);
210+
}
211+
catch (InterruptedException ex) {
212+
Thread.currentThread().interrupt();
213+
}
214+
}
215+
}
216+
217+
/**
218+
* Unblock a parked {@link InputProvider#readInput()} so {@link #stop()} can return.
219+
*/
220+
protected void wakeup() {
221+
}
222+
223+
@Override
224+
public void destroy() {
225+
stop();
226+
}
227+
172228
}

spring-shell-jline/src/main/java/org/springframework/shell/jline/JLineShellRunner.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
import java.io.Console;
1919
import java.io.PrintWriter;
2020

21+
import org.apache.commons.logging.Log;
22+
import org.apache.commons.logging.LogFactory;
2123
import org.jline.reader.LineReader;
24+
import org.jline.terminal.Terminal;
2225

2326
import org.springframework.shell.core.InputReader;
2427
import org.springframework.shell.core.InteractiveShellRunner;
@@ -29,10 +32,13 @@
2932
* Interactive shell runner based on the JVM's system {@link Console}.
3033
*
3134
* @author Mahmoud Ben Hassine
35+
* @author David Pilar
3236
* @since 4.0.0
3337
*/
3438
public class JLineShellRunner extends InteractiveShellRunner {
3539

40+
private static final Log log = LogFactory.getLog(JLineShellRunner.class);
41+
3642
private final LineReader lineReader;
3743

3844
/**
@@ -67,4 +73,20 @@ public InputReader getReader() {
6773
return new JLineInputReader(this.lineReader);
6874
}
6975

76+
/**
77+
* Raise {@code INT} on the terminal so the blocked {@link LineReader#readLine()}
78+
* throws.
79+
*/
80+
@Override
81+
protected void wakeup() {
82+
try {
83+
this.lineReader.getTerminal().raise(Terminal.Signal.INT);
84+
}
85+
catch (Exception ex) {
86+
if (log.isDebugEnabled()) {
87+
log.debug("Failed to raise INT on terminal to wake up line reader", ex);
88+
}
89+
}
90+
}
91+
7092
}

0 commit comments

Comments
 (0)