This is an automated email from the ASF dual-hosted git repository.
tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new 2275a5d8c3 [ZEPPELIN-6705] Refactor shell terminal session management
2275a5d8c3 is described below
commit 2275a5d8c367c534980a2ff73cc834a1d9f46508
Author: SeungYoung Oh <[email protected]>
AuthorDate: Sun Sep 13 15:49:55 2026 +0900
[ZEPPELIN-6705] Refactor shell terminal session management
### What is this PR for?
This PR refactors `TerminalService` into `TerminalSession` to clarify its
role.
The name `~Service` suggests a long-lived component providing shared
functionality. However, each instance owns the state and resources of a single
terminal connection and must be closed when that connection closes. `~Session`
better reflects this ownership and lifecycle. This mismatch may have
contributed to the missing cleanup in #5447.
This PR also includes minor code cleanup.
### What type of PR is it?
Refactoring
### Todos
### What is the Jira issue?
- [ZEPPELIN-6705]
### How should this be tested?
- Run the shell module tests: `./mvnw test -pl shell.`
### Screenshots (if appropriate)
### Questions:
* Does the license files need to update? N
* Is there breaking changes for older versions? N
* Does this needs documentation? N
Closes #5473 from seung-00/ZEPPELIN-6705.
Signed-off-by: ChanHo Lee <[email protected]>
---
.../zeppelin/shell/terminal/TerminalManager.java | 32 ++--
.../TerminalService.java => TerminalSession.java} | 174 ++++++++++-----------
.../shell/terminal/websocket/TerminalSocket.java | 42 ++---
.../zeppelin/shell/TerminalInterpreterTest.java | 15 +-
.../shell/terminal/TerminalSessionTest.java | 128 +++++++++++++++
5 files changed, 251 insertions(+), 140 deletions(-)
diff --git
a/shell/src/main/java/org/apache/zeppelin/shell/terminal/TerminalManager.java
b/shell/src/main/java/org/apache/zeppelin/shell/terminal/TerminalManager.java
index 71d9ff1279..92cbb30a85 100644
---
a/shell/src/main/java/org/apache/zeppelin/shell/terminal/TerminalManager.java
+++
b/shell/src/main/java/org/apache/zeppelin/shell/terminal/TerminalManager.java
@@ -18,13 +18,13 @@
package org.apache.zeppelin.shell.terminal;
import org.apache.zeppelin.interpreter.InterpreterContext;
-import org.apache.zeppelin.shell.terminal.service.TerminalService;
import org.apache.zeppelin.shell.terminal.websocket.TerminalSocket;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import static
org.apache.zeppelin.shell.TerminalInterpreter.TERMINAL_SOCKET_CLOSE;
import static
org.apache.zeppelin.shell.TerminalInterpreter.TERMINAL_SOCKET_CONNECT;
@@ -35,8 +35,7 @@ import static
org.apache.zeppelin.shell.TerminalInterpreter.TERMINAL_SOCKET_STAT
public class TerminalManager {
private static final Logger LOGGER =
LoggerFactory.getLogger(TerminalManager.class);
- // TerminalSocket hashCode -> TerminalService
- private HashMap<Integer, TerminalService> terminalSocket2Service;
+ private final Map<TerminalSocket, TerminalSession> terminalSocket2Session;
// NoteId@ParagraphId -> InterpreterContext
private HashMap<String, InterpreterContext> noteParagraphId2IntpContext;
@@ -51,27 +50,18 @@ public class TerminalManager {
}
private TerminalManager() {
- terminalSocket2Service = new HashMap<>();
+ terminalSocket2Session = new ConcurrentHashMap<>();
noteParagraphId2IntpContext = new HashMap<>();
}
- public TerminalService addTerminalService(TerminalSocket terminalSocket) {
- Integer terminalSocketHashcode = terminalSocket.hashCode();
- if (terminalSocket2Service.containsKey(terminalSocketHashcode)) {
- return terminalSocket2Service.get(terminalSocketHashcode);
- } else {
- TerminalService terminalService = new TerminalService();
- terminalSocket2Service.put(terminalSocketHashcode, terminalService);
- return terminalService;
- }
+ public void addTerminalSession(TerminalSocket terminalSocket,
TerminalSession terminalSession) {
+ terminalSocket2Session.put(terminalSocket, terminalSession);
}
- public void removeTerminalService(TerminalSocket terminalSocket) {
- Integer terminalSocketHashcode = terminalSocket.hashCode();
- if (terminalSocket2Service.containsKey(terminalSocketHashcode)) {
- terminalSocket2Service.remove(terminalSocketHashcode).close();
- } else {
- LOGGER.error("Can't find TerminalSocket: {}", terminalSocketHashcode);
+ public void removeTerminalSession(TerminalSocket terminalSocket) {
+ TerminalSession session = terminalSocket2Session.remove(terminalSocket);
+ if (session != null) {
+ session.close();
}
}
@@ -87,7 +77,7 @@ public class TerminalManager {
}
public void runCommand(String command) {
- for (Map.Entry<Integer, TerminalService> entry :
terminalSocket2Service.entrySet()) {
+ for (Map.Entry<TerminalSocket, TerminalSession> entry :
terminalSocket2Session.entrySet()) {
entry.getValue().onCommand(command + "\r");
}
}
@@ -122,7 +112,7 @@ public class TerminalManager {
LOGGER.error("Can't find InterpreterContext from : {}", id);
}
- removeTerminalService(terminalSocket);
+ removeTerminalSession(terminalSocket);
}
// Socket error will trigger socket close
diff --git
a/shell/src/main/java/org/apache/zeppelin/shell/terminal/service/TerminalService.java
b/shell/src/main/java/org/apache/zeppelin/shell/terminal/TerminalSession.java
similarity index 52%
rename from
shell/src/main/java/org/apache/zeppelin/shell/terminal/service/TerminalService.java
rename to
shell/src/main/java/org/apache/zeppelin/shell/terminal/TerminalSession.java
index 609dbe91a6..7605fbeb55 100644
---
a/shell/src/main/java/org/apache/zeppelin/shell/terminal/service/TerminalService.java
+++
b/shell/src/main/java/org/apache/zeppelin/shell/terminal/TerminalSession.java
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-package org.apache.zeppelin.shell.terminal.service;
+package org.apache.zeppelin.shell.terminal;
import com.google.gson.Gson;
import com.pty4j.PtyProcess;
@@ -28,76 +28,107 @@ import org.slf4j.LoggerFactory;
import jakarta.websocket.Session;
import java.io.BufferedReader;
import java.io.BufferedWriter;
+import java.io.Closeable;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
-import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
-public class TerminalService implements AutoCloseable {
- private static final Logger LOGGER =
LoggerFactory.getLogger(TerminalService.class);
+public class TerminalSession implements AutoCloseable {
+ private static final Logger LOGGER =
LoggerFactory.getLogger(TerminalSession.class);
+ private static final Gson gson = new Gson();
+
+ private static final int DEFAULT_COLUMNS = 20;
+ private static final int DEFAULT_ROWS = 10;
+ private final AtomicBoolean closed = new AtomicBoolean();
- private String[] termCommand;
private PtyProcess process;
- private Integer columns = 20;
- private Integer rows = 10;
private BufferedReader inputReader;
private BufferedReader errorReader;
private BufferedWriter outputWriter;
private Session webSocketSession;
- private LinkedBlockingQueue<String> commandQueue = new
LinkedBlockingQueue<>();
+ private final ExecutorService commandExecutor =
Executors.newSingleThreadExecutor();
+ private final ExecutorService readerExecutor =
Executors.newFixedThreadPool(2);
- public void onTerminalReady() {
- TerminalService.startThread(() -> {
- try {
- initializeProcess();
- } catch (Exception e) {
- LOGGER.error(e.getMessage(), e);
- }
- });
+ public TerminalSession(Session webSocketSession) throws IOException {
+ this(webSocketSession, startProcess());
}
- private void initializeProcess() throws Exception {
- LOGGER.info("initialize TerminalService Process");
+ TerminalSession(Session webSocketSession, PtyProcess process) {
+ this.process = process;
+ this.webSocketSession = webSocketSession;
+ try {
+ webSocketSession.setMaxIdleTimeout(60 * 60 * 1000);
+ inputReader = new BufferedReader(new
InputStreamReader(process.getInputStream()));
+ errorReader = new BufferedReader(new
InputStreamReader(process.getErrorStream()));
+ outputWriter = new BufferedWriter(new
OutputStreamWriter(process.getOutputStream()));
+ readerExecutor.execute(() -> printReader(inputReader));
+ readerExecutor.execute(() -> printReader(errorReader));
+ } catch (RuntimeException e) {
+ close();
+ throw e;
+ }
+ }
+ private static PtyProcess startProcess() throws IOException {
boolean isWindows = System.getProperty("os.name").startsWith("Windows");
+ String[] termCommand;
if (isWindows) {
- this.termCommand = "cmd.exe".split("\\s+");
+ termCommand = "cmd.exe".split("\\s+");
} else {
- this.termCommand = "/bin/bash -i".split("\\s+");
+ termCommand = "/bin/bash -i".split("\\s+");
}
Map<String, String> envs = new HashMap<>(System.getenv());
envs.put("TERM", "xterm");
- this.process = new
PtyProcessBuilder().setCommand(termCommand).setEnvironment(envs).start();
-
- process.setWinSize(new WinSize(columns, rows));
- this.inputReader = new BufferedReader(new
InputStreamReader(process.getInputStream()));
- this.errorReader = new BufferedReader(new
InputStreamReader(process.getErrorStream()));
- this.outputWriter = new BufferedWriter(new
OutputStreamWriter(process.getOutputStream()));
-
- TerminalService.startThread(() -> {
- printReader(inputReader);
- });
+ return new PtyProcessBuilder()
+ .setCommand(termCommand)
+ .setEnvironment(envs)
+ .setInitialColumns(DEFAULT_COLUMNS)
+ .setInitialRows(DEFAULT_ROWS)
+ .start();
+ }
- TerminalService.startThread(() -> {
- printReader(errorReader);
- });
+ public void onCommand(String command) {
+ if (StringUtils.isEmpty(command)) {
+ return;
+ }
- process.waitFor();
+ try {
+ commandExecutor.execute(() -> {
+ try {
+ outputWriter.write(command);
+ outputWriter.flush();
+ } catch (IOException e) {
+ LOGGER.error(e.getMessage(), e);
+ }
+ });
+ } catch (RejectedExecutionException e) {
+ if (!commandExecutor.isShutdown()) {
+ throw e;
+ }
+ }
}
- private void print(String text) throws IOException {
- Map<String, String> map = new HashMap<>();
- map.put("type", "TERMINAL_PRINT");
- map.put("text", text);
+ public void onTerminalResize(String columns, String rows) {
+ if (Objects.nonNull(columns) && Objects.nonNull(rows)) {
+ if (!closed.get()) {
+ process.setWinSize(new WinSize(Integer.parseInt(columns),
Integer.parseInt(rows)));
+ }
+ }
+ }
- Gson gson = new Gson();
+ private synchronized void print(String text) throws IOException {
+ Map<String, String> map = Map.of("type", "TERMINAL_PRINT", "text", text);
String message = gson.toJson(map);
webSocketSession.getBasicRemote().sendText(message);
}
@@ -117,56 +148,23 @@ public class TerminalService implements AutoCloseable {
}
}
- public void onCommand(String command) {
- if (null == command || StringUtils.isEmpty(command)) {
+ @Override
+ public void close() {
+ if (!closed.compareAndSet(false, true)) {
return;
}
-
- try {
- commandQueue.put(command);
- } catch (InterruptedException e) {
- LOGGER.error(e.getMessage(), e);
- }
-
- TerminalService.startThread(() -> {
+ commandExecutor.shutdownNow();
+ readerExecutor.shutdownNow();
+ destroyProcess(process);
+ for (Closeable stream : new Closeable[] {outputWriter, inputReader,
errorReader}) {
+ if (stream == null) {
+ continue;
+ }
try {
- outputWriter.write(commandQueue.poll());
- outputWriter.flush();
+ stream.close();
} catch (IOException e) {
LOGGER.error(e.getMessage(), e);
}
- });
- }
-
- public void onTerminalResize(String columns, String rows) {
- if (Objects.nonNull(columns) && Objects.nonNull(rows)) {
- this.columns = Integer.valueOf(columns);
- this.rows = Integer.valueOf(rows);
-
- if (Objects.nonNull(process)) {
- process.setWinSize(new WinSize(this.columns, this.rows));
- }
- }
- }
-
- @Override
- public void close() {
- try {
- if (outputWriter != null) {
- outputWriter.close();
- }
- if (inputReader != null) {
- inputReader.close();
- }
- if (errorReader != null) {
- errorReader.close();
- }
- } catch (IOException e) {
- LOGGER.error(e.getMessage(), e);
- }
-
- if (process != null) {
- destroyProcess(process);
}
}
@@ -181,14 +179,4 @@ public class TerminalService implements AutoCloseable {
process.destroyForcibly();
}
}
-
- public void onWebSocketConnect(Session webSocketSession) {
- this.webSocketSession = webSocketSession;
- webSocketSession.setMaxIdleTimeout(60 * 60 * 1000);
- }
-
- public static void startThread(Runnable runnable) {
- Thread thread = new Thread(runnable);
- thread.start();
- }
}
diff --git
a/shell/src/main/java/org/apache/zeppelin/shell/terminal/websocket/TerminalSocket.java
b/shell/src/main/java/org/apache/zeppelin/shell/terminal/websocket/TerminalSocket.java
index 9a9a7f497e..476c6e632b 100644
---
a/shell/src/main/java/org/apache/zeppelin/shell/terminal/websocket/TerminalSocket.java
+++
b/shell/src/main/java/org/apache/zeppelin/shell/terminal/websocket/TerminalSocket.java
@@ -20,11 +20,10 @@ package org.apache.zeppelin.shell.terminal.websocket;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.apache.zeppelin.shell.terminal.TerminalManager;
-import org.apache.zeppelin.shell.terminal.service.TerminalService;
+import org.apache.zeppelin.shell.terminal.TerminalSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import jakarta.websocket.ClientEndpoint;
import jakarta.websocket.CloseReason;
import jakarta.websocket.EndpointConfig;
import jakarta.websocket.OnClose;
@@ -39,7 +38,6 @@ import java.security.MessageDigest;
import java.util.List;
import java.util.Map;
-@ClientEndpoint
@ServerEndpoint(value = "/")
public class TerminalSocket {
private static final Logger LOGGER =
LoggerFactory.getLogger(TerminalSocket.class);
@@ -47,17 +45,16 @@ public class TerminalSocket {
// Key under which TerminalThread publishes the per-server auth token
public static final String AUTH_TOKEN_PROPERTY =
"zeppelin.terminal.auth.token";
- private TerminalService terminalService;
+ private static final Gson gson = new Gson();
+
+ private TerminalSession terminalSession;
private TerminalManager terminalManager = TerminalManager.getInstance();
+ private Session webSocketSession;
private String noteId;
private String paragraphId;
private volatile boolean authorized = false;
- public TerminalSocket() {
- terminalService = terminalManager.addTerminalService(this);
- }
-
@OnOpen
public void onWebSocketConnect(Session sess, EndpointConfig config) {
// This endpoint hands out an OS shell: require the per-server secret that
@@ -77,7 +74,7 @@ public class TerminalSocket {
}
authorized = true;
LOGGER.info("Socket Connected: {}", sess.getId());
- terminalService.onWebSocketConnect(sess);
+ this.webSocketSession = sess;
}
@OnMessage
@@ -96,16 +93,28 @@ public class TerminalSocket {
String type = messageMap.get("type");
switch (type) {
case "TERMINAL_READY":
- terminalService.onTerminalReady();
this.noteId = messageMap.get("noteId");
this.paragraphId = messageMap.get("paragraphId");
+ if (terminalSession == null) {
+ try {
+ terminalSession = new TerminalSession(webSocketSession);
+ terminalManager.addTerminalSession(this, terminalSession);
+ } catch (IOException e) {
+ LOGGER.error(e.getMessage(), e);
+ break;
+ }
+ }
terminalManager.onWebSocketConnect(noteId, paragraphId);
break;
case "TERMINAL_COMMAND":
- terminalService.onCommand(messageMap.get("command"));
+ if (terminalSession != null) {
+ terminalSession.onCommand(messageMap.get("command"));
+ }
break;
case "TERMINAL_RESIZE":
- terminalService.onTerminalResize(messageMap.get("columns"),
messageMap.get("rows"));
+ if (terminalSession != null) {
+ terminalSession.onTerminalResize(messageMap.get("columns"),
messageMap.get("rows"));
+ }
break;
default:
LOGGER.error("Unrecognized action: {}", message);
@@ -116,10 +125,8 @@ public class TerminalSocket {
@OnClose
public void onWebSocketClose(CloseReason reason) {
LOGGER.info("Socket Closed: {}", reason);
- if (authorized && noteId != null && paragraphId != null) {
+ if (authorized && terminalSession != null) {
terminalManager.onWebSocketClose(this, noteId, paragraphId);
- } else {
- terminalManager.removeTerminalService(this);
}
}
@@ -142,9 +149,6 @@ public class TerminalSocket {
}
private Map<String, String> getMessageMap(String message) {
- Gson gson = new Gson();
- Map<String, String> map = gson.fromJson(message,
- new TypeToken<Map<String, String>>(){}.getType());
- return map;
+ return gson.fromJson(message, new TypeToken<Map<String,
String>>(){}.getType());
}
}
diff --git
a/shell/src/test/java/org/apache/zeppelin/shell/TerminalInterpreterTest.java
b/shell/src/test/java/org/apache/zeppelin/shell/TerminalInterpreterTest.java
index 646029a522..14ba94e5cc 100644
--- a/shell/src/test/java/org/apache/zeppelin/shell/TerminalInterpreterTest.java
+++ b/shell/src/test/java/org/apache/zeppelin/shell/TerminalInterpreterTest.java
@@ -47,7 +47,8 @@ import static org.junit.jupiter.api.Assertions.fail;
import java.io.IOException;
import java.net.URI;
import java.util.Properties;
-import java.util.regex.Pattern;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
class TerminalInterpreterTest extends BaseInterpreterTest {
private static final Logger LOGGER =
LoggerFactory.getLogger(TerminalInterpreterTest.class);
@@ -113,9 +114,9 @@ class TerminalInterpreterTest extends BaseInterpreterTest {
LOGGER.info(msg);
// {"text":"\u001b[?1034hbash-3.2$ \r\u001b[Kbash-3.2$
// \r\u001b[Kbash-3.2$ ","type":"TERMINAL_PRINT"}
- String pattern = "\\{\"text\":\".*\"type\":\"TERMINAL_PRINT\"}";
- boolean isMatch = Pattern.matches(pattern, msg);
- assertTrue(isMatch);
+ JsonObject message = JsonParser.parseString(msg).getAsJsonObject();
+ assertEquals("TERMINAL_PRINT", message.get("type").getAsString());
+ assertTrue(message.get("text").isJsonPrimitive());
// Send invalid_command message
String echoHelloWorldCmd =
String.format("{\"type\":\"TERMINAL_COMMAND\"," +
@@ -199,9 +200,9 @@ class TerminalInterpreterTest extends BaseInterpreterTest {
LOGGER.info(msg);
// {"text":"\u001b[?1034hbash-3.2$ \r\u001b[Kbash-3.2$
// \r\u001b[Kbash-3.2$ ","type":"TERMINAL_PRINT"}
- String pattern = "\\{\"text\":\".*\"type\":\"TERMINAL_PRINT\"}";
- boolean isMatch = Pattern.matches(pattern, msg);
- assertTrue(isMatch);
+ JsonObject message = JsonParser.parseString(msg).getAsJsonObject();
+ assertEquals("TERMINAL_PRINT", message.get("type").getAsString());
+ assertTrue(message.get("text").isJsonPrimitive());
// Send echo 'hello world!' message
String echoHelloWorldCmd =
String.format("{\"type\":\"TERMINAL_COMMAND\"," +
diff --git
a/shell/src/test/java/org/apache/zeppelin/shell/terminal/TerminalSessionTest.java
b/shell/src/test/java/org/apache/zeppelin/shell/terminal/TerminalSessionTest.java
new file mode 100644
index 0000000000..49cc5c6a74
--- /dev/null
+++
b/shell/src/test/java/org/apache/zeppelin/shell/terminal/TerminalSessionTest.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.apache.zeppelin.shell.terminal;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.never;
+
+import com.pty4j.PtyProcess;
+import jakarta.websocket.Session;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+class TerminalSessionTest {
+ @Test
+ void commandsUseOneWorkerInOrderAndAllWorkersStopOnClose() throws Exception {
+ CountDownLatch reads = new CountDownLatch(2);
+ Set<Thread> readerWorkers = ConcurrentHashMap.newKeySet();
+ InputStream input = mock(InputStream.class);
+ when(input.read(any(byte[].class), anyInt(),
anyInt())).thenAnswer(invocation -> {
+ readerWorkers.add(Thread.currentThread());
+ reads.countDown();
+ return -1;
+ });
+ CountDownLatch writes = new CountDownLatch(100);
+ Set<Thread> workers = new HashSet<>();
+ ByteArrayOutputStream output = new ByteArrayOutputStream() {
+ @Override
+ public synchronized void write(byte[] bytes, int offset, int length) {
+ workers.add(Thread.currentThread());
+ super.write(bytes, offset, length);
+ writes.countDown();
+ }
+ };
+ PtyProcess process = mock(PtyProcess.class);
+ when(process.getInputStream()).thenReturn(input);
+ when(process.getErrorStream()).thenReturn(input);
+ when(process.getOutputStream()).thenReturn(output);
+ when(process.waitFor(5L, TimeUnit.SECONDS)).thenReturn(true);
+
+ StringBuilder expected = new StringBuilder();
+ TerminalSession terminal = new TerminalSession(mock(Session.class),
process);
+ try {
+ assertTrue(reads.await(5, TimeUnit.SECONDS));
+ assertEquals(2, readerWorkers.size());
+ terminal.onCommand(null);
+ terminal.onCommand("");
+ for (int i = 0; i < 100; i++) {
+ String command = "command-" + i + "\r";
+ expected.append(command);
+ terminal.onCommand(command);
+ }
+ assertTrue(writes.await(5, TimeUnit.SECONDS));
+ assertEquals(expected.toString(), output.toString());
+ assertEquals(1, workers.size());
+ } finally {
+ terminal.close();
+ }
+
+ assertDoesNotThrow(() -> terminal.onCommand("after close"));
+ terminal.close();
+ verify(process).destroy();
+ verify(process, never()).destroyForcibly();
+ workers.addAll(readerWorkers);
+ for (Thread worker : workers) {
+ worker.join(5000);
+ assertFalse(worker.isAlive());
+ }
+ assertEquals(expected.toString(), output.toString());
+ }
+
+ @Test
+ void closeForciblyDestroysProcessThatDoesNotExit() throws Exception {
+ PtyProcess process = mock(PtyProcess.class);
+ when(process.getInputStream()).thenReturn(InputStream.nullInputStream());
+ when(process.getErrorStream()).thenReturn(InputStream.nullInputStream());
+ when(process.getOutputStream()).thenReturn(new ByteArrayOutputStream());
+ TerminalSession terminal = new TerminalSession(mock(Session.class),
process);
+
+ terminal.close();
+ terminal.close();
+
+ verify(process).destroy();
+ verify(process).destroyForcibly();
+ }
+
+ @Test
+ void initializationFailureDestroysProcess() throws Exception {
+ PtyProcess process = mock(PtyProcess.class);
+ when(process.getInputStream()).thenThrow(new IllegalStateException("stream
unavailable"));
+ when(process.waitFor(5L, TimeUnit.SECONDS)).thenReturn(true);
+
+ assertThrows(IllegalStateException.class,
+ () -> new TerminalSession(mock(Session.class), process));
+
+ verify(process).destroy();
+ }
+
+}