From 88bac27429d90c867fd769d340a99e3be989ceb0 Mon Sep 17 00:00:00 2001 From: David Pilar Date: Sat, 27 Jun 2026 09:00:00 +0200 Subject: [PATCH] Handle closed terminal gracefully on shell exit Resolves #1363 Signed-off-by: David Pilar --- .../shell/jline/JLineInputProvider.java | 15 ++- .../shell/jline/JLineShellRunner.java | 15 ++- .../shell/jline/JLineShellRunnerTests.java | 93 +++++++++++++++++++ 3 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 spring-shell-jline/src/test/java/org/springframework/shell/jline/JLineShellRunnerTests.java diff --git a/spring-shell-jline/src/main/java/org/springframework/shell/jline/JLineInputProvider.java b/spring-shell-jline/src/main/java/org/springframework/shell/jline/JLineInputProvider.java index 636365c39..d0293370e 100644 --- a/spring-shell-jline/src/main/java/org/springframework/shell/jline/JLineInputProvider.java +++ b/spring-shell-jline/src/main/java/org/springframework/shell/jline/JLineInputProvider.java @@ -23,9 +23,18 @@ public JLineInputProvider(LineReader lineReader) { @Override public String readInput() { - AttributedString prompt = this.promptProvider.getPrompt(); - String ansiPrompt = prompt.toAnsi(this.lineReader.getTerminal()); - return this.lineReader.readLine(ansiPrompt); + try { + AttributedString prompt = this.promptProvider.getPrompt(); + String ansiPrompt = prompt.toAnsi(this.lineReader.getTerminal()); + return this.lineReader.readLine(ansiPrompt); + } + catch (IllegalStateException ex) { + // Terminal closed externally (e.g. process killed from an IDE): treat as EOF. + if (ex.getMessage() != null && ex.getMessage().contains("closed")) { + return null; + } + throw ex; + } } public void setPromptProvider(PromptProvider promptProvider) { 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 7420a6929..7f32c305a 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 @@ -55,12 +55,23 @@ public JLineShellRunner(JLineInputProvider inputProvider, CommandParser commandP @Override public void print(String message) { - this.lineReader.getTerminal().writer().println(message); + ignoringClosedTerminal(() -> this.lineReader.getTerminal().writer().println(message)); } @Override public void flush() { - lineReader.getTerminal().flush(); + ignoringClosedTerminal(() -> this.lineReader.getTerminal().flush()); + } + + private void ignoringClosedTerminal(Runnable action) { + try { + action.run(); + } + catch (IllegalStateException ex) { + if (ex.getMessage() == null || !ex.getMessage().contains("closed")) { + throw ex; + } + } } @Override diff --git a/spring-shell-jline/src/test/java/org/springframework/shell/jline/JLineShellRunnerTests.java b/spring-shell-jline/src/test/java/org/springframework/shell/jline/JLineShellRunnerTests.java new file mode 100644 index 000000000..f8474337b --- /dev/null +++ b/spring-shell-jline/src/test/java/org/springframework/shell/jline/JLineShellRunnerTests.java @@ -0,0 +1,93 @@ +/* + * 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.jline; + +import java.io.ByteArrayOutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; + +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminal; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.shell.core.command.CommandRegistry; +import org.springframework.shell.core.command.DefaultCommandParser; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNull; + +class JLineShellRunnerTests { + + private PipedInputStream pipedInputStream; + + private PipedOutputStream pipedOutputStream; + + private Terminal terminal; + + private JLineInputProvider inputProvider; + + private JLineShellRunner runner; + + @BeforeEach + void setup() throws Exception { + this.pipedInputStream = new PipedInputStream(); + this.pipedOutputStream = new PipedOutputStream(); + this.pipedInputStream.connect(this.pipedOutputStream); + ByteArrayOutputStream consoleOut = new ByteArrayOutputStream(); + this.terminal = new DumbTerminal("terminal", "ansi", this.pipedInputStream, consoleOut, StandardCharsets.UTF_8); + LineReader lineReader = LineReaderBuilder.builder().terminal(this.terminal).build(); + this.inputProvider = new JLineInputProvider(lineReader); + CommandRegistry commandRegistry = new CommandRegistry(); + this.runner = new JLineShellRunner(this.inputProvider, new DefaultCommandParser(commandRegistry), + commandRegistry); + } + + @AfterEach + void cleanup() throws Exception { + this.pipedOutputStream.close(); + this.pipedInputStream.close(); + } + + @Test + void runShouldExitGracefullyWhenTerminalIsClosed() throws Exception { + this.terminal.close(); + assertDoesNotThrow(() -> this.runner.run(new String[0])); + } + + @Test + void readInputShouldReturnNullWhenTerminalIsClosed() throws Exception { + this.terminal.close(); + assertNull(this.inputProvider.readInput()); + } + + @Test + void printShouldNotThrowWhenTerminalIsClosed() throws Exception { + this.terminal.close(); + assertDoesNotThrow(() -> this.runner.print("anything")); + } + + @Test + void flushShouldNotThrowWhenTerminalIsClosed() throws Exception { + this.terminal.close(); + assertDoesNotThrow(() -> this.runner.flush()); + } + +}