Skip to content

Commit 93c87e9

Browse files
authored
Merge pull request #3318 from seigert/fix/io-read_input_stream-overallocation
Fix 'fs.io.readInputStreamGeneric' overallocation of underlying buffers
2 parents 62d25fa + ca5b901 commit 93c87e9

File tree

4 files changed

+56
-19
lines changed

4 files changed

+56
-19
lines changed

build.sbt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ ThisBuild / mimaBinaryIssueFilters ++= Seq(
236236
),
237237
ProblemFilters.exclude[InheritedNewAbstractMethodProblem](
238238
"fs2.io.file.Files.openSeekableByteChannel"
239+
),
240+
// package-private method: #3318
241+
ProblemFilters.exclude[IncompatibleMethTypeProblem](
242+
"fs2.io.package.readInputStreamGeneric"
239243
)
240244
)
241245

io/jvm/src/main/scala/fs2/io/ioplatform.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ private[fs2] trait ioplatform extends iojvmnative {
4343
F.pure(System.in),
4444
F.delay(new Array[Byte](bufSize)),
4545
false
46-
) { (is, buf) =>
46+
) { (is, buf, off) =>
4747
F.async[Int] { cb =>
4848
F.delay {
49-
val task: Runnable = () => cb(Right(is.read(buf)))
49+
val task: Runnable = () => cb(Right(is.read(buf, off, buf.length - off)))
5050
stdinExecutor.submit(task)
5151
}.map { fut =>
5252
Some(F.delay {

io/jvm/src/test/scala/fs2/io/IoPlatformSuite.scala

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,42 @@ class IoPlatformSuite extends Fs2Suite {
4040
// This suite runs for a long time, this avoids timeouts in CI.
4141
override def munitIOTimeout: Duration = 2.minutes
4242

43+
group("readInputStream") {
44+
test("reuses internal buffer on smaller chunks") {
45+
forAllF { (bytes: Array[Byte], chunkSize0: Int) =>
46+
val chunkSize = (chunkSize0 % 20).abs + 1
47+
fs2.Stream
48+
.chunk(Chunk.array(bytes))
49+
.chunkN(chunkSize / 3 + 1)
50+
.unchunks
51+
.covary[IO]
52+
// we know that '.toInputStream' reads by chunk
53+
.through(fs2.io.toInputStream)
54+
.flatMap(is => io.readInputStream(IO(is), chunkSize))
55+
.chunks
56+
.zipWithPrevious
57+
.assertForall {
58+
case (None, _) => true // skip first element
59+
case (_, _: Chunk.Singleton[_]) => true // skip singleton bytes
60+
case (Some(_: Chunk.Singleton[_]), _) => true // skip singleton bytes
61+
case (Some(Chunk.ArraySlice(bs1, o1, l1)), Chunk.ArraySlice(bs2, o2, _)) =>
62+
{
63+
// if first slice buffer is not 'full'
64+
(bs1.length != (o1 + l1)) &&
65+
// we expect that next slice will wrap same buffer
66+
((bs2 eq bs1) && (o2 == o1 + l1))
67+
} || {
68+
// if first slice buffer is 'full'
69+
(bs2.length == (o1 + l1)) &&
70+
// we expect new buffer allocated for next slice
71+
((bs2 ne bs1) && (o2 == 0))
72+
}
73+
case _ => false // unexpected chunk subtype
74+
}
75+
}
76+
}
77+
}
78+
4379
group("readOutputStream") {
4480
test("writes data and terminates when `f` returns") {
4581
forAllF { (bytes: Array[Byte], chunkSize0: Int) =>

io/shared/src/main/scala/fs2/io/io.scala

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ package object io extends ioplatform {
4545
fis,
4646
F.delay(new Array[Byte](chunkSize)),
4747
closeAfterUse
48-
)((is, buf) => F.blocking(is.read(buf)))
48+
)((is, buf, off) => F.blocking(is.read(buf, off, buf.length - off)))
4949

5050
private[io] def readInputStreamCancelable[F[_]](
5151
fis: F[InputStream],
@@ -57,7 +57,7 @@ package object io extends ioplatform {
5757
fis,
5858
F.delay(new Array[Byte](chunkSize)),
5959
closeAfterUse
60-
)((is, buf) => F.blocking(is.read(buf)).cancelable(cancel))
60+
)((is, buf, off) => F.blocking(is.read(buf, off, buf.length - off)).cancelable(cancel))
6161

6262
/** Reads all bytes from the specified `InputStream` with a buffer size of `chunkSize`.
6363
* Set `closeAfterUse` to false if the `InputStream` should not be closed after use.
@@ -76,31 +76,28 @@ package object io extends ioplatform {
7676
fis,
7777
F.pure(new Array[Byte](chunkSize)),
7878
closeAfterUse
79-
)((is, buf) => F.blocking(is.read(buf)))
79+
)((is, buf, off) => F.blocking(is.read(buf, off, buf.length - off)))
8080

81-
private def readBytesFromInputStream[F[_]](is: InputStream, buf: Array[Byte])(
82-
read: (InputStream, Array[Byte]) => F[Int]
81+
private def readBytesFromInputStream[F[_]](is: InputStream, buf: Array[Byte], offset: Int)(
82+
read: (InputStream, Array[Byte], Int) => F[Int]
8383
)(implicit
8484
F: Sync[F]
85-
): F[Option[Chunk[Byte]]] =
86-
read(is, buf).map { numBytes =>
85+
): F[Option[(Chunk[Byte], Option[(Array[Byte], Int)])]] =
86+
read(is, buf, offset).map { numBytes =>
8787
if (numBytes < 0) None
88-
else if (numBytes == 0) Some(Chunk.empty)
89-
else if (numBytes < buf.size) Some(Chunk.array(buf, 0, numBytes))
90-
else Some(Chunk.array(buf))
88+
else if (offset + numBytes == buf.size) Some(Chunk.array(buf, offset, numBytes) -> None)
89+
else Some(Chunk.array(buf, offset, numBytes) -> Some(buf -> (offset + numBytes)))
9190
}
9291

9392
private[fs2] def readInputStreamGeneric[F[_]](
9493
fis: F[InputStream],
9594
buf: F[Array[Byte]],
9695
closeAfterUse: Boolean
97-
)(read: (InputStream, Array[Byte]) => F[Int])(implicit F: Sync[F]): Stream[F, Byte] = {
98-
def useIs(is: InputStream) =
99-
Stream
100-
.eval(buf.flatMap(b => readBytesFromInputStream(is, b)(read)))
101-
.repeat
102-
.unNoneTerminate
103-
.flatMap(c => Stream.chunk(c))
96+
)(read: (InputStream, Array[Byte], Int) => F[Int])(implicit F: Sync[F]): Stream[F, Byte] = {
97+
def useIs(is: InputStream) = Stream.unfoldChunkEval(Option.empty[(Array[Byte], Int)]) {
98+
case None => buf.flatMap(b => readBytesFromInputStream(is, b, 0)(read))
99+
case Some((b, offset)) => readBytesFromInputStream(is, b, offset)(read)
100+
}
104101

105102
if (closeAfterUse)
106103
Stream.bracket(fis)(is => Sync[F].blocking(is.close())).flatMap(useIs)

0 commit comments

Comments
 (0)