Skip to content

Commit 0ac019a

Browse files
authored
fix: terminal.close() blocks when pump thread is reading stdin (#1911)
* fix: terminal.close() blocks when pump thread is reading stdin (#1909) On macOS, a pump thread blocked in a native read() on a tty can prevent tcsetattr() from completing, causing terminal.close() to hang until the user presses ENTER. Fix by switching to non-canonical mode with VMIN=0/VTIME=1 before shutdown, which forces the blocked read() to time out within 100 ms so the pump thread can exit cleanly before original terminal attributes are restored. Also improve NonBlockingInputStreamImpl and NonBlockingReaderImpl shutdown: set threadIsReading=false, interrupt the thread, and join with a timeout so close() reliably stops the pump thread. * fix: ensure pump thread shutdown is robust on close - AbstractUnixSysTerminal: wrap VMIN/VTIME unblock sequence in try-finally so input.shutdown() runs even if doGetAttributes() or doSetAttributes() throws - NonBlockingInputStreamImpl/ReaderImpl: close the wrapped stream before shutdown/join so the native read() is unblocked before we wait for the pump thread to exit * refactor: extract NonCloseable stream wrappers and add lifecycle tests Extract NonCloseableInputStream and NonCloseableOutputStream from DumbTerminalProvider into org.jline.utils for reuse. Apply them in AbstractUnixSysTerminal to prevent closing shared FileDescriptors (stdin/stdout/stderr) when the terminal is closed. Add timeout logging to pump thread shutdown in NonBlockingInputStreamImpl and NonBlockingReaderImpl. Add comprehensive tests for stream wrapper behavior and pump thread lifecycle. * fix: wrap ExecPty system streams with NonCloseable wrappers ExecPty.doGetSlaveInput() and getSlaveOutput() returned raw FileInputStream/FileOutputStream on shared FileDescriptors when used as a system terminal. Closing the terminal would close the shared FDs, breaking System.in/out/err for the rest of the JVM. Wrap the system stream cases with NonCloseableInputStream and NonCloseableOutputStream, matching AbstractUnixSysTerminal and DumbTerminalProvider. Device-file paths are left unwrapped since those are non-shared FDs. * refactor: remove VMIN/VTIME unblock sequence from doClose() The VMIN/VTIME trick cannot reliably unblock a native read() that is already in progress — tcsetattr only affects the next read call, not the current one. The NonCloseable wrappers are the actual fix: they prevent FileDescriptor.close0() from being called, which eliminates the macOS deadlock. The pump thread exits on its next iteration once the current read completes naturally. * fix: reduce shutdown join timeout and prevent double-wait Reduce the thread join timeout from 500ms to 50ms — the thread either exits promptly (when waiting) or is stuck in a native read (where no timeout helps). Setting thread = null after join prevents the second shutdown() call in the close chain from waiting again. * refactor: extract PumpThread to deduplicate thread lifecycle management NonBlockingInputStreamImpl and NonBlockingReaderImpl had identical startReadingThreadIfNeeded(), shutdown(), and thread management fields. Extract into a shared PumpThread helper that both classes delegate to. * refactor: move run loop into PumpThread to further reduce duplication The run() method was still duplicated between NonBlockingInputStreamImpl and NonBlockingReaderImpl. Move the loop into PumpThread.runLoop() with IoReader and ResultHandler callbacks. Each Impl class now has a one-liner run() that delegates to pump.runLoop(). * fix: address SonarCloud issues in PumpThread Store lock as a field instead of passing as method parameter (S2445). Use notifyAll instead of notify (S2446). Re-interrupt on InterruptedException in runLoop (S2142).
1 parent 1d63740 commit 0ac019a

10 files changed

Lines changed: 571 additions & 229 deletions

terminal/src/main/java/org/jline/terminal/impl/AbstractUnixSysTerminal.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
import org.jline.utils.NonBlocking;
3232
import org.jline.utils.NonBlockingInputStream;
3333
import org.jline.utils.NonBlockingReader;
34+
import org.jline.utils.NonCloseableInputStream;
35+
import org.jline.utils.NonCloseableOutputStream;
3436
import org.jline.utils.ShutdownHooks;
3537
import org.jline.utils.ShutdownHooks.Task;
3638

@@ -50,6 +52,12 @@
5052
* <pre>
5153
* Terminal → AbstractTerminal → AbstractUnixSysTerminal → subclass → native call
5254
* </pre>
55+
*
56+
* <p><strong>Important:</strong> the underlying system streams ({@code FileDescriptor.in},
57+
* {@code FileDescriptor.out}/{@code err}) are wrapped in {@link NonCloseableInputStream} /
58+
* {@link NonCloseableOutputStream}. Closing the terminal will shut down the pump thread and
59+
* release resources, but will <em>not</em> close the shared file descriptors. This prevents
60+
* breaking {@code System.in}/{@code System.out} for the rest of the JVM.</p>
5361
*/
5462
public abstract class AbstractUnixSysTerminal extends AbstractTerminal {
5563

@@ -87,7 +95,8 @@ protected AbstractUnixSysTerminal(
8795
this.originalAttributes = originalAttributes;
8896
this.nativeSignals = nativeSignals;
8997

90-
this.input = NonBlocking.nonBlocking(getName(), new FileInputStream(FileDescriptor.in));
98+
this.input =
99+
NonBlocking.nonBlocking(getName(), new NonCloseableInputStream(new FileInputStream(FileDescriptor.in)));
91100
FileDescriptor outFd;
92101
if (systemStream == SystemStream.Output) {
93102
outFd = FileDescriptor.out;
@@ -96,7 +105,7 @@ protected AbstractUnixSysTerminal(
96105
} else {
97106
throw new IllegalArgumentException("Invalid system stream for output: " + systemStream);
98107
}
99-
this.output = new FastBufferedOutputStream(new FileOutputStream(outFd));
108+
this.output = new FastBufferedOutputStream(new NonCloseableOutputStream(new FileOutputStream(outFd)));
100109
this.reader = NonBlocking.nonBlocking(getName(), input, inputEncoding());
101110
this.writer = new PrintWriter(new OutputStreamWriter(output, outputEncoding()));
102111

@@ -231,9 +240,13 @@ protected void doClose() throws IOException {
231240
super.doClose();
232241
} finally {
233242
try {
234-
doSetAttributes(originalAttributes);
243+
input.close();
235244
} finally {
236-
reader.close();
245+
try {
246+
doSetAttributes(originalAttributes);
247+
} finally {
248+
reader.close();
249+
}
237250
}
238251
}
239252
}

terminal/src/main/java/org/jline/terminal/impl/DumbTerminalProvider.java

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
import java.io.FileDescriptor;
1212
import java.io.FileInputStream;
1313
import java.io.FileOutputStream;
14-
import java.io.FilterInputStream;
15-
import java.io.FilterOutputStream;
1614
import java.io.IOException;
1715
import java.io.InputStream;
1816
import java.io.OutputStream;
@@ -24,6 +22,8 @@
2422
import org.jline.terminal.TerminalBuilder;
2523
import org.jline.terminal.spi.SystemStream;
2624
import org.jline.terminal.spi.TerminalProvider;
25+
import org.jline.utils.NonCloseableInputStream;
26+
import org.jline.utils.NonCloseableOutputStream;
2727

2828
/**
2929
* Terminal provider implementation for dumb terminals.
@@ -127,35 +127,4 @@ public int systemStreamWidth(SystemStream stream) {
127127
public String toString() {
128128
return "TerminalProvider[" + name() + "]";
129129
}
130-
131-
/**
132-
* Wrapper that prevents closing the underlying input stream.
133-
* Used for system streams (System.in) to prevent closing the FileDescriptor.
134-
*/
135-
private static class NonCloseableInputStream extends FilterInputStream {
136-
NonCloseableInputStream(InputStream in) {
137-
super(in);
138-
}
139-
140-
@Override
141-
public void close() throws IOException {
142-
// Do not close the underlying stream
143-
}
144-
}
145-
146-
/**
147-
* Wrapper that prevents closing the underlying output stream.
148-
* Used for system streams (System.out/err) to prevent closing the FileDescriptor.
149-
*/
150-
private static class NonCloseableOutputStream extends FilterOutputStream {
151-
NonCloseableOutputStream(OutputStream out) {
152-
super(out);
153-
}
154-
155-
@Override
156-
public void close() throws IOException {
157-
// Flush but do not close the underlying stream
158-
flush();
159-
}
160-
}
161130
}

terminal/src/main/java/org/jline/terminal/impl/exec/ExecPty.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
import org.jline.terminal.spi.Pty;
3232
import org.jline.terminal.spi.SystemStream;
3333
import org.jline.terminal.spi.TerminalProvider;
34+
import org.jline.utils.NonCloseableInputStream;
35+
import org.jline.utils.NonCloseableOutputStream;
3436
import org.jline.utils.OSUtils;
3537

3638
import static org.jline.utils.ExecHelper.exec;
@@ -154,15 +156,17 @@ public OutputStream getMasterOutput() {
154156

155157
@Override
156158
protected InputStream doGetSlaveInput() throws IOException {
157-
return systemStream != null ? new FileInputStream(FileDescriptor.in) : new FileInputStream(getName());
159+
return systemStream != null
160+
? new NonCloseableInputStream(new FileInputStream(FileDescriptor.in))
161+
: new FileInputStream(getName());
158162
}
159163

160164
@Override
161165
public OutputStream getSlaveOutput() throws IOException {
162166
return systemStream == SystemStream.Output
163-
? new FileOutputStream(FileDescriptor.out)
167+
? new NonCloseableOutputStream(new FileOutputStream(FileDescriptor.out))
164168
: systemStream == SystemStream.Error
165-
? new FileOutputStream(FileDescriptor.err)
169+
? new NonCloseableOutputStream(new FileOutputStream(FileDescriptor.err))
166170
: new FileOutputStream(getName());
167171
}
168172

terminal/src/main/java/org/jline/utils/NonBlockingInputStreamImpl.java

Lines changed: 21 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,8 @@ public class NonBlockingInputStreamImpl extends NonBlockingInputStream {
3131
private int b = READ_EXPIRED; // Recently read byte
3232

3333
private String name;
34-
private boolean threadIsReading = false;
3534
private IOException exception = null;
36-
private long threadDelay = 60 * 1000;
37-
private Thread thread;
35+
private final PumpThread pump;
3836

3937
/**
4038
* Creates a <code>NonBlockingReader</code> out of a normal blocking
@@ -44,40 +42,25 @@ public class NonBlockingInputStreamImpl extends NonBlockingInputStream {
4442
* @param name The stream name
4543
* @param in The reader to wrap
4644
*/
45+
@SuppressWarnings("this-escape")
4746
public NonBlockingInputStreamImpl(String name, InputStream in) {
4847
this.in = in;
4948
this.name = name;
49+
this.pump = new PumpThread(this, 60_000);
5050
}
5151

52-
private synchronized void startReadingThreadIfNeeded() {
53-
if (thread == null) {
54-
thread = new Thread(this::run);
55-
thread.setName(name + " non blocking reader thread");
56-
thread.setDaemon(true);
57-
thread.start();
58-
}
59-
}
60-
61-
/**
62-
* Shuts down the thread that is handling blocking I/O. Note that if the
63-
* thread is currently blocked waiting for I/O it will not actually
64-
* shut down until the I/O is received.
65-
*/
66-
public synchronized void shutdown() {
67-
if (thread != null) {
68-
notify();
69-
}
52+
public void shutdown() {
53+
pump.shutdown();
7054
}
7155

7256
@Override
7357
public void close() throws IOException {
74-
/*
75-
* The underlying input stream is closed first. This means that if the
76-
* I/O thread was blocked waiting on input, it will be woken for us.
77-
*/
78-
super.close(); // Mark as closed in base class
79-
in.close();
80-
shutdown();
58+
super.close();
59+
try {
60+
in.close();
61+
} finally {
62+
pump.shutdown();
63+
}
8164
}
8265

8366
/**
@@ -110,15 +93,15 @@ public synchronized int read(long timeout, boolean isPeek) throws IOException {
11093
*/
11194
if (b >= -1) {
11295
assert exception == null;
113-
} else if (!isPeek && timeout <= 0L && !threadIsReading) {
96+
} else if (!isPeek && timeout <= 0L && !pump.isReading()) {
11497
b = in.read();
11598
} else {
11699
/*
117100
* If the thread isn't reading already, then ask it to do so.
118101
*/
119-
if (!threadIsReading) {
120-
threadIsReading = true;
121-
startReadingThreadIfNeeded();
102+
if (!pump.isReading()) {
103+
pump.setReading(true);
104+
pump.startIfNeeded(this::run, name);
122105
notifyAll();
123106
}
124107

@@ -166,66 +149,12 @@ public synchronized int read(long timeout, boolean isPeek) throws IOException {
166149
}
167150

168151
private void run() {
169-
Log.debug("NonBlockingInputStream start");
170-
boolean needToRead;
171-
172-
try {
173-
while (true) {
174-
175-
/*
176-
* Synchronize to grab variables accessed by both this thread
177-
* and the accessing thread.
178-
*/
179-
synchronized (this) {
180-
needToRead = this.threadIsReading;
181-
182-
try {
183-
/*
184-
* Nothing to do? Then wait.
185-
*/
186-
if (!needToRead) {
187-
wait(threadDelay);
188-
}
189-
} catch (InterruptedException e) {
190-
/* IGNORED */
191-
}
192-
193-
needToRead = this.threadIsReading;
194-
if (!needToRead) {
195-
return;
196-
}
197-
}
198-
199-
/*
200-
* We're not shutting down, but we need to read. This cannot
201-
* happen while we are holding the lock (which we aren't now).
202-
*/
203-
int byteRead = READ_EXPIRED;
204-
IOException failure = null;
205-
try {
206-
byteRead = in.read();
207-
} catch (IOException e) {
208-
failure = e;
209-
}
210-
211-
/*
212-
* Re-grab the lock to update the state.
213-
*/
214-
synchronized (this) {
152+
pump.runLoop(
153+
in::read,
154+
(value, failure) -> {
215155
exception = failure;
216-
b = byteRead;
217-
threadIsReading = false;
218-
notify();
219-
}
220-
}
221-
} catch (Throwable t) {
222-
Log.warn("Error in NonBlockingInputStream thread", t);
223-
} finally {
224-
Log.debug("NonBlockingInputStream shutdown");
225-
synchronized (this) {
226-
thread = null;
227-
threadIsReading = false;
228-
}
229-
}
156+
b = value;
157+
},
158+
"NonBlockingInputStream");
230159
}
231160
}

0 commit comments

Comments
 (0)