Skip to content

Commit 798dc01

Browse files
authored
4.x: Fix Streamable.fromStream leaking the Stream when the first hasNext fails (#8331)
StreamableFromStream.stream() called items.iterator() and hasNext() outside of any try block. If the Stream throws there (an already consumed Stream, or a pipeline stage failing on the first element), the exception escaped stream() synchronously and the Stream was never closed. Catch it like StreamableFromIterable does, close the Stream and return a failed Streamer instead.
1 parent b773b7b commit 798dc01

2 files changed

Lines changed: 46 additions & 6 deletions

File tree

‎src/main/java/io/reactivex/rxjava4/internal/operators/streamable/StreamableFromStream.java‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
package io.reactivex.rxjava4.internal.operators.streamable;
1515

16-
import java.util.Objects;
16+
import java.util.*;
1717
import java.util.stream.Stream;
1818

1919
import io.reactivex.rxjava4.annotations.NonNull;
@@ -27,8 +27,22 @@ public record StreamableFromStream<T>(@NonNull Stream<? extends T> items) implem
2727
@SuppressWarnings("unchecked")
2828
@Override
2929
public @NonNull Streamer<@NonNull T> stream(@NonNull StreamerCancellation cancellation) {
30-
var iterator = Objects.requireNonNull(items.iterator(), "iterator is null");
31-
if (!iterator.hasNext()) {
30+
Iterator<? extends T> iterator;
31+
boolean hasNext;
32+
try {
33+
iterator = Objects.requireNonNull(items.iterator(), "iterator is null");
34+
hasNext = iterator.hasNext();
35+
} catch (Throwable ex) {
36+
Exceptions.throwIfFatal(ex);
37+
try {
38+
items.close();
39+
} catch (Throwable exc) {
40+
Exceptions.throwIfFatal(exc);
41+
ex.addSuppressed(exc);
42+
}
43+
return StreamableError.createFailed(ex);
44+
}
45+
if (!hasNext) {
3246
try {
3347
items.close();
3448
} catch (Throwable ex) {

‎src/test/java/io/reactivex/rxjava4/internal/operators/streamable/StreamableFromStreamTest.java‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@
1313

1414
package io.reactivex.rxjava4.internal.operators.streamable;
1515

16-
import static org.junit.jupiter.api.Assertions.assertTrue;
16+
import static org.junit.jupiter.api.Assertions.*;
1717

1818
import java.util.*;
1919
import java.util.concurrent.TimeUnit;
20-
import java.util.concurrent.atomic.AtomicBoolean;
20+
import java.util.concurrent.atomic.*;
2121
import java.util.stream.Stream;
2222

2323
import org.junit.jupiter.api.Test;
2424

25-
import io.reactivex.rxjava4.core.Streamable;
25+
import io.reactivex.rxjava4.core.*;
2626
import io.reactivex.rxjava4.exceptions.TestException;
2727

2828
public class StreamableFromStreamTest extends StreamableBaseTest {
@@ -120,4 +120,30 @@ public void ensureCloses2Crash() {
120120

121121
assertTrue(hasClosed.get(), "Close not called");
122122
}
123+
124+
@Test
125+
public void firstItemCrashCloses() {
126+
var hasClosed = new AtomicBoolean();
127+
Streamable.fromStream(Stream.of(1, 2, 3).<Integer>map(_ -> { throw new TestException(); })
128+
.onClose(() -> hasClosed.set(true)))
129+
.test()
130+
.awaitDone(5, TimeUnit.SECONDS)
131+
.assertFailure(TestException.class);
132+
133+
assertTrue(hasClosed.get(), "Close not called");
134+
}
135+
136+
@Test
137+
public void alreadyConsumedStreamSink() throws Throwable {
138+
var stream = Stream.of(1, 2, 3);
139+
stream.forEach(_ -> { });
140+
141+
var error = new AtomicReference<Throwable>();
142+
Streamable.fromStream(stream)
143+
.subscribe(StreamSink.create(_ -> Streamer.NEXT_TRUE, e -> { error.set(e); return Streamer.FINISHED; }))
144+
.toCompletableFuture()
145+
.get(5, TimeUnit.SECONDS);
146+
147+
assertInstanceOf(IllegalStateException.class, error.get());
148+
}
123149
}

0 commit comments

Comments
 (0)