Skip to content

Commit 1bdd1ac

Browse files
gnodetGuillaume Nodet
andauthored
chore: fix intermittent encoding test failures by handling READ_EXPIRED correctly (#1303)
Fixes #1296 The MultiEncodingTerminalTest was experiencing intermittent failures on Windows CI environments showing replacement characters (￾) in test output. Root Cause: The issue was NOT in the NonBlocking implementation, but in test logic that incorrectly handled READ_EXPIRED (-2) return values from NonBlockingReader. When NonBlockingReader.read(timeout) times out, it returns READ_EXPIRED (-2). Test code was casting this directly to char: (char)(-2) = U+FFFE = ￾ Fixed Tests: - MultiEncodingTerminalTest.testReadWithEncoding - MultiEncodingTerminalTest.testMultipleEncodings - Added comprehensive NonBlockingEncodingTest suite The fix properly distinguishes between: - Valid characters (c >= 0) - EOF (-1) - READ_EXPIRED (-2) - timeout condition Tests now retry on timeout instead of treating it as a valid character, resolving the intermittent Windows CI failures. Co-authored-by: Guillaume Nodet <[email protected]>
1 parent 0cfb48c commit 1bdd1ac

File tree

2 files changed

+296
-4
lines changed

2 files changed

+296
-4
lines changed

terminal/src/test/java/org/jline/terminal/MultiEncodingTerminalTest.java

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,17 @@ public void testReadWithEncoding() throws IOException {
5555
NonBlockingReader reader = terminal.reader();
5656
StringBuilder result = new StringBuilder();
5757
int c;
58-
while ((c = reader.read(1)) != -1) {
59-
result.append((char) c);
58+
int timeoutCount = 0;
59+
while (timeoutCount < 1000) { // Allow up to 1000 timeouts before giving up
60+
c = reader.read(1);
61+
if (c == -1) { // EOF
62+
break;
63+
} else if (c == -2) { // READ_EXPIRED (timeout)
64+
timeoutCount++;
65+
continue; // Keep trying
66+
} else if (c >= 0) { // Valid character
67+
result.append((char) c);
68+
}
6069
}
6170

6271
// Verify the text was correctly decoded using ISO-8859-1
@@ -136,8 +145,17 @@ public void testMultipleEncodings() throws IOException {
136145
NonBlockingReader reader = terminal.reader();
137146
StringBuilder result = new StringBuilder();
138147
int c;
139-
while ((c = reader.read(1)) != -1) {
140-
result.append((char) c);
148+
int timeoutCount = 0;
149+
while (timeoutCount < 1000) { // Allow up to 1000 timeouts before giving up
150+
c = reader.read(1);
151+
if (c == -1) { // EOF
152+
break;
153+
} else if (c == -2) { // READ_EXPIRED (timeout)
154+
timeoutCount++;
155+
continue; // Keep trying
156+
} else if (c >= 0) { // Valid character
157+
result.append((char) c);
158+
}
141159
}
142160

143161
// Write to stdout (UTF-16)
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
/*
2+
* Copyright (c) 2002-2024, the original author(s).
3+
*
4+
* This software is distributable under the BSD license. See the terms of the
5+
* BSD license in the documentation provided with this software.
6+
*
7+
* https://opensource.org/licenses/BSD-3-Clause
8+
*/
9+
package org.jline.utils;
10+
11+
import java.io.IOException;
12+
import java.io.InputStream;
13+
import java.nio.charset.StandardCharsets;
14+
import java.util.concurrent.atomic.AtomicInteger;
15+
16+
import org.junit.jupiter.api.Test;
17+
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
20+
/**
21+
* Test for NonBlockingInputStreamReader encoding issues.
22+
* This test attempts to reproduce the intermittent encoding failures
23+
* seen in Windows CI environments.
24+
*/
25+
public class NonBlockingEncodingTest {
26+
27+
/**
28+
* Test that simulates slow byte-by-byte reading with timeouts
29+
* to force decoder underflow conditions.
30+
*/
31+
@Test
32+
public void testSlowByteByByteReading() throws IOException {
33+
String testString = "café";
34+
byte[] bytes = testString.getBytes(StandardCharsets.ISO_8859_1);
35+
36+
// Create a slow input stream that introduces delays between bytes
37+
SlowInputStream slowInput = new SlowInputStream(bytes, 10); // 10ms delay between bytes
38+
39+
NonBlockingReader reader = NonBlocking.nonBlocking("test", slowInput, StandardCharsets.ISO_8859_1);
40+
41+
StringBuilder result = new StringBuilder();
42+
int c;
43+
int readCount = 0;
44+
int timeoutCount = 0;
45+
while (readCount < testString.length() && timeoutCount < 100) { // Allow up to 100 timeouts
46+
c = reader.read(1); // 1ms timeout - shorter than byte delay
47+
if (c == -1) { // EOF
48+
break;
49+
} else if (c == -2) { // READ_EXPIRED (timeout)
50+
timeoutCount++;
51+
continue; // Keep trying
52+
} else if (c >= 0) { // Valid character
53+
result.append((char) c);
54+
readCount++;
55+
System.out.println("Read " + readCount + ": '" + (char) c + "' (0x" + Integer.toHexString(c) + ")");
56+
}
57+
}
58+
59+
System.out.println("Expected: '" + testString + "'");
60+
System.out.println("Actual: '" + result.toString() + "'");
61+
assertEquals(testString, result.toString(), "Slow byte-by-byte reading should preserve encoding");
62+
}
63+
64+
/**
65+
* Test that simulates the exact conditions from MultiEncodingTerminalTest
66+
* but with controlled timing.
67+
*/
68+
@Test
69+
public void testControlledTimingReading() throws IOException {
70+
String testString = "café";
71+
byte[] bytes = testString.getBytes(StandardCharsets.ISO_8859_1);
72+
73+
// Create input stream with specific timing patterns
74+
TimedInputStream timedInput = new TimedInputStream(bytes);
75+
76+
NonBlockingReader reader = NonBlocking.nonBlocking("test", timedInput, StandardCharsets.ISO_8859_1);
77+
78+
StringBuilder result = new StringBuilder();
79+
int c;
80+
int timeoutCount = 0;
81+
while (timeoutCount < 1000) { // Allow up to 1000 timeouts before giving up
82+
c = reader.read(1);
83+
if (c == -1) { // EOF
84+
break;
85+
} else if (c == -2) { // READ_EXPIRED (timeout)
86+
timeoutCount++;
87+
continue; // Keep trying
88+
} else if (c >= 0) { // Valid character
89+
result.append((char) c);
90+
}
91+
}
92+
93+
assertEquals(testString, result.toString(), "Controlled timing should preserve encoding");
94+
}
95+
96+
/**
97+
* Test with multiple iterations to catch intermittent issues.
98+
*/
99+
@Test
100+
public void testRepeatedReading() throws IOException {
101+
String testString = "café";
102+
103+
for (int i = 0; i < 100; i++) {
104+
byte[] bytes = testString.getBytes(StandardCharsets.ISO_8859_1);
105+
SlowInputStream slowInput = new SlowInputStream(bytes, 1 + (i % 5)); // Variable delays
106+
107+
NonBlockingReader reader = NonBlocking.nonBlocking("test-" + i, slowInput, StandardCharsets.ISO_8859_1);
108+
109+
StringBuilder result = new StringBuilder();
110+
int c;
111+
int timeoutCount = 0;
112+
while (timeoutCount < 1000) { // Allow up to 1000 timeouts before giving up
113+
c = reader.read(1);
114+
if (c == -1) { // EOF
115+
break;
116+
} else if (c == -2) { // READ_EXPIRED (timeout)
117+
timeoutCount++;
118+
continue; // Keep trying
119+
} else if (c >= 0) { // Valid character
120+
result.append((char) c);
121+
}
122+
}
123+
124+
assertEquals(testString, result.toString(), "Iteration " + i + " should preserve encoding");
125+
}
126+
}
127+
128+
/**
129+
* Test with buffer boundary conditions.
130+
*/
131+
@Test
132+
public void testBufferBoundaryConditions() throws IOException {
133+
String testString = "café";
134+
byte[] bytes = testString.getBytes(StandardCharsets.ISO_8859_1);
135+
136+
// Test with input that forces buffer compacting
137+
BufferStressingInputStream stressingInput = new BufferStressingInputStream(bytes);
138+
139+
NonBlockingReader reader = NonBlocking.nonBlocking("test", stressingInput, StandardCharsets.ISO_8859_1);
140+
141+
StringBuilder result = new StringBuilder();
142+
int c;
143+
int timeoutCount = 0;
144+
while (timeoutCount < 1000) { // Allow up to 1000 timeouts before giving up
145+
c = reader.read(1);
146+
if (c == -1) { // EOF
147+
break;
148+
} else if (c == -2) { // READ_EXPIRED (timeout)
149+
timeoutCount++;
150+
continue; // Keep trying
151+
} else if (c >= 0) { // Valid character
152+
result.append((char) c);
153+
}
154+
}
155+
156+
assertEquals(testString, result.toString(), "Buffer boundary conditions should preserve encoding");
157+
}
158+
159+
/**
160+
* Input stream that introduces delays between bytes to simulate slow reading.
161+
*/
162+
private static class SlowInputStream extends InputStream {
163+
private final byte[] data;
164+
private final long delayMs;
165+
private final AtomicInteger position = new AtomicInteger(0);
166+
167+
public SlowInputStream(byte[] data, long delayMs) {
168+
this.data = data;
169+
this.delayMs = delayMs;
170+
}
171+
172+
@Override
173+
public int read() throws IOException {
174+
int pos = position.getAndIncrement();
175+
if (pos >= data.length) {
176+
return -1;
177+
}
178+
179+
if (pos > 0 && delayMs > 0) {
180+
try {
181+
Thread.sleep(delayMs);
182+
} catch (InterruptedException e) {
183+
Thread.currentThread().interrupt();
184+
throw new IOException("Interrupted", e);
185+
}
186+
}
187+
188+
return data[pos] & 0xFF;
189+
}
190+
191+
@Override
192+
public int available() throws IOException {
193+
return Math.max(0, data.length - position.get());
194+
}
195+
}
196+
197+
/**
198+
* Input stream with specific timing patterns to trigger decoder issues.
199+
*/
200+
private static class TimedInputStream extends InputStream {
201+
private final byte[] data;
202+
private final AtomicInteger position = new AtomicInteger(0);
203+
204+
public TimedInputStream(byte[] data) {
205+
this.data = data;
206+
}
207+
208+
@Override
209+
public int read() throws IOException {
210+
int pos = position.getAndIncrement();
211+
if (pos >= data.length) {
212+
return -1;
213+
}
214+
215+
// Introduce specific delays for the 'é' character (0xE9 in ISO-8859-1)
216+
if (pos == 3 && data[pos] == (byte) 0xE9) {
217+
try {
218+
Thread.sleep(5); // Longer delay for the problematic character
219+
} catch (InterruptedException e) {
220+
Thread.currentThread().interrupt();
221+
throw new IOException("Interrupted", e);
222+
}
223+
}
224+
225+
return data[pos] & 0xFF;
226+
}
227+
228+
@Override
229+
public int available() throws IOException {
230+
return Math.max(0, data.length - position.get());
231+
}
232+
}
233+
234+
/**
235+
* Input stream that stresses buffer management by returning bytes in patterns
236+
* that force buffer compacting and state management.
237+
*/
238+
private static class BufferStressingInputStream extends InputStream {
239+
private final byte[] data;
240+
private final AtomicInteger position = new AtomicInteger(0);
241+
private int readCount = 0;
242+
243+
public BufferStressingInputStream(byte[] data) {
244+
this.data = data;
245+
}
246+
247+
@Override
248+
public int read() throws IOException {
249+
int pos = position.getAndIncrement();
250+
if (pos >= data.length) {
251+
return -1;
252+
}
253+
254+
readCount++;
255+
256+
// Force timeout every few reads to stress buffer management
257+
if (readCount % 3 == 0) {
258+
try {
259+
Thread.sleep(2); // Force timeout in NonBlockingReader
260+
} catch (InterruptedException e) {
261+
Thread.currentThread().interrupt();
262+
throw new IOException("Interrupted", e);
263+
}
264+
}
265+
266+
return data[pos] & 0xFF;
267+
}
268+
269+
@Override
270+
public int available() throws IOException {
271+
return Math.max(0, data.length - position.get());
272+
}
273+
}
274+
}

0 commit comments

Comments
 (0)