From 1f07787ed87738194924c590937f6e21e5612b14 Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sun, 4 Feb 2024 09:41:08 -0500 Subject: [PATCH 01/43] Improve performance of Files.walk on the JVM --- .../scala/fs2/io/file/WalkBenchmark.scala | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala diff --git a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala new file mode 100644 index 0000000000..893bb08f56 --- /dev/null +++ b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala @@ -0,0 +1,83 @@ + +/* + * Copyright (c) 2013 Functional Streams for Scala + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package fs2 +package io +package file + +import cats.effect.IO +import java.io.File +import scala.concurrent.duration.* + +class WalkBenchmark extends Fs2IoSuite { + + private var target: Path = _ + + override def beforeAll() = { + super.beforeAll() + val file = File.createTempFile("fs2-benchmarks-", "-walk") + file.delete() + file.mkdir() + target = Path(file.toString) + + val MaxDepth = 7 + val Names = 'A'.to('E').toList.map(_.toString) + + def loop(cwd: File, depth: Int): Unit = { + if (depth < MaxDepth) { + Names foreach { name => + val sub = new File(cwd, name) + sub.mkdir() + loop(sub, depth + 1) + } + } else if (depth == MaxDepth) { + Names foreach { name => + val sub = new File(cwd, name) + sub.createNewFile() + loop(sub, depth + 1) + } + } + } + + loop(file, 0) + } + + def time[A](f: => A): FiniteDuration = { + val start = System.nanoTime() + val _ = f + (System.nanoTime() - start).nanos + } + + + test("Files.walk has similar performance to java.nio.file.Files.walk") { + val fs2Time = time(Files[IO] + .walk(target) + .compile + .count + .unsafeRunSync()) + val nioTime = time(java.nio.file.Files.walk(target.toNioPath).count()) + val epsilon = nioTime.toNanos * 1.5 + println(s"fs2 took: ${fs2Time.toMillis} ms") + println(s"nio took: ${nioTime.toMillis} ms") + assert((fs2Time - nioTime).toNanos.abs < epsilon, s"fs2 time: $fs2Time, nio time: $nioTime, diff: ${fs2Time - nioTime}") + } +} From e9bf35ab9cf7d1b84a7598907c8920d1eeb976dc Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sun, 4 Feb 2024 09:44:39 -0500 Subject: [PATCH 02/43] Scalafmt --- .../scala/fs2/io/file/FilesPlatform.scala | 54 ++++++++++++++++++- .../scala/fs2/io/file/WalkBenchmark.scala | 30 ++++++----- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala index 590da203af..8dc1b8c2d0 100644 --- a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -24,6 +24,7 @@ package io package file import cats.effect.kernel.{Async, Resource, Sync} +import cats.effect.std.Dispatcher import cats.syntax.all._ import java.nio.channels.{FileChannel, SeekableByteChannel} @@ -32,15 +33,16 @@ import java.nio.file.attribute.{ BasicFileAttributeView, BasicFileAttributes => JBasicFileAttributes, PosixFileAttributes => JPosixFileAttributes, - PosixFilePermissions + PosixFilePermissions, + FileTime } import java.security.Principal import java.util.stream.{Stream => JStream} import scala.concurrent.duration._ +import fs2.concurrent.Channel import fs2.io.CollectionCompat._ -import java.nio.file.attribute.FileTime private[file] trait FilesPlatform[F[_]] extends DeprecatedFilesApi[F] { self: Files[F] => @@ -389,6 +391,54 @@ private[file] trait FilesCompanionPlatform { .resource(Resource.fromAutoCloseable(javaCollection)) .flatMap(ds => Stream.fromBlockingIterator[F](collectionIterator(ds), pathStreamChunkSize)) + override def walk(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = + Stream.resource(Dispatcher.sequential[F]).flatMap { dispatcher => + Stream.eval(Channel.bounded[F, Chunk[Path]](10)).flatMap { channel => + val doWalk = Sync[F].interruptibleMany { + val bldr = Vector.newBuilder[Path] + val limit = 4096 + var size = 0 + bldr.sizeHint(limit) + JFiles.walkFileTree( + start.toNioPath, + if (followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, + maxDepth, + new SimpleFileVisitor[JPath] { + private def enqueue(path: JPath): FileVisitResult = { + bldr += Path.fromNioPath(path) + size += 1 + if (size >= limit) { + val result = dispatcher.unsafeRunSync(channel.send(Chunk.from(bldr.result()))) + bldr.clear() + size = 0 + if (result.isRight) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE + } else FileVisitResult.CONTINUE + } + + override def visitFile(file: JPath, attrs: JBasicFileAttributes): FileVisitResult = + enqueue(file) + + override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = + FileVisitResult.CONTINUE + + override def preVisitDirectory(dir: JPath, attrs: JBasicFileAttributes) + : FileVisitResult = + enqueue(dir) + + override def postVisitDirectory(dir: JPath, t: IOException): FileVisitResult = + FileVisitResult.CONTINUE + } + ) + + dispatcher.unsafeRunSync( + if (size > 0) channel.closeWithElement(Chunk.from(bldr.result())) + else channel.close + ) + } + channel.stream.unchunks.concurrently(Stream.eval(doWalk)) + } + } + def createWatcher: Resource[F, Watcher[F]] = Watcher.default(this, F) def watch( diff --git a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala index 893bb08f56..1874ddd95d 100644 --- a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala +++ b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala @@ -1,4 +1,3 @@ - /* * Copyright (c) 2013 Functional Streams for Scala * @@ -33,7 +32,7 @@ class WalkBenchmark extends Fs2IoSuite { private var target: Path = _ override def beforeAll() = { - super.beforeAll() + super.beforeAll() val file = File.createTempFile("fs2-benchmarks-", "-walk") file.delete() file.mkdir() @@ -42,21 +41,20 @@ class WalkBenchmark extends Fs2IoSuite { val MaxDepth = 7 val Names = 'A'.to('E').toList.map(_.toString) - def loop(cwd: File, depth: Int): Unit = { + def loop(cwd: File, depth: Int): Unit = if (depth < MaxDepth) { - Names foreach { name => + Names.foreach { name => val sub = new File(cwd, name) sub.mkdir() loop(sub, depth + 1) } } else if (depth == MaxDepth) { - Names foreach { name => + Names.foreach { name => val sub = new File(cwd, name) sub.createNewFile() loop(sub, depth + 1) } } - } loop(file, 0) } @@ -67,17 +65,21 @@ class WalkBenchmark extends Fs2IoSuite { (System.nanoTime() - start).nanos } - - test("Files.walk has similar performance to java.nio.file.Files.walk") { - val fs2Time = time(Files[IO] - .walk(target) - .compile - .count - .unsafeRunSync()) + test("Files.walk has similar performance to java.nio.file.Files.walk") { + val fs2Time = time( + Files[IO] + .walk(target) + .compile + .count + .unsafeRunSync() + ) val nioTime = time(java.nio.file.Files.walk(target.toNioPath).count()) val epsilon = nioTime.toNanos * 1.5 println(s"fs2 took: ${fs2Time.toMillis} ms") println(s"nio took: ${nioTime.toMillis} ms") - assert((fs2Time - nioTime).toNanos.abs < epsilon, s"fs2 time: $fs2Time, nio time: $nioTime, diff: ${fs2Time - nioTime}") + assert( + (fs2Time - nioTime).toNanos.abs < epsilon, + s"fs2 time: $fs2Time, nio time: $nioTime, diff: ${fs2Time - nioTime}" + ) } } From 81eee78daaa65d8b13ab6d52ca5fb5d6a3963359 Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sun, 4 Feb 2024 14:55:25 -0500 Subject: [PATCH 03/43] Fix native --- .../scala/fs2/io/file/FilesPlatform.scala | 53 +------- .../fs2/io/file/AsyncFilesPlatform.scala | 128 ++++++++++++++++++ .../scala/fs2/io/file/WalkBenchmark.scala | 10 +- .../fs2/io/file/AsyncFilesPlatform.scala | 29 ++++ .../src/main/scala/fs2/io/file/Files.scala | 19 ++- 5 files changed, 185 insertions(+), 54 deletions(-) create mode 100644 io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala create mode 100644 io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala diff --git a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala index 8dc1b8c2d0..5fa635e5f3 100644 --- a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -24,7 +24,6 @@ package io package file import cats.effect.kernel.{Async, Resource, Sync} -import cats.effect.std.Dispatcher import cats.syntax.all._ import java.nio.channels.{FileChannel, SeekableByteChannel} @@ -41,7 +40,6 @@ import java.util.stream.{Stream => JStream} import scala.concurrent.duration._ -import fs2.concurrent.Channel import fs2.io.CollectionCompat._ private[file] trait FilesPlatform[F[_]] extends DeprecatedFilesApi[F] { self: Files[F] => @@ -93,7 +91,8 @@ private[file] trait FilesCompanionPlatform { private case class NioFileKey(value: AnyRef) extends FileKey private final class AsyncFiles[F[_]](protected implicit val F: Async[F]) - extends Files.UnsealedFiles[F] { + extends Files.UnsealedFiles[F] + with AsyncFilesPlatform[F] { def copy(source: Path, target: Path, flags: CopyFlags): F[Unit] = Sync[F].blocking { @@ -391,54 +390,6 @@ private[file] trait FilesCompanionPlatform { .resource(Resource.fromAutoCloseable(javaCollection)) .flatMap(ds => Stream.fromBlockingIterator[F](collectionIterator(ds), pathStreamChunkSize)) - override def walk(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = - Stream.resource(Dispatcher.sequential[F]).flatMap { dispatcher => - Stream.eval(Channel.bounded[F, Chunk[Path]](10)).flatMap { channel => - val doWalk = Sync[F].interruptibleMany { - val bldr = Vector.newBuilder[Path] - val limit = 4096 - var size = 0 - bldr.sizeHint(limit) - JFiles.walkFileTree( - start.toNioPath, - if (followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, - maxDepth, - new SimpleFileVisitor[JPath] { - private def enqueue(path: JPath): FileVisitResult = { - bldr += Path.fromNioPath(path) - size += 1 - if (size >= limit) { - val result = dispatcher.unsafeRunSync(channel.send(Chunk.from(bldr.result()))) - bldr.clear() - size = 0 - if (result.isRight) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE - } else FileVisitResult.CONTINUE - } - - override def visitFile(file: JPath, attrs: JBasicFileAttributes): FileVisitResult = - enqueue(file) - - override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = - FileVisitResult.CONTINUE - - override def preVisitDirectory(dir: JPath, attrs: JBasicFileAttributes) - : FileVisitResult = - enqueue(dir) - - override def postVisitDirectory(dir: JPath, t: IOException): FileVisitResult = - FileVisitResult.CONTINUE - } - ) - - dispatcher.unsafeRunSync( - if (size > 0) channel.closeWithElement(Chunk.from(bldr.result())) - else channel.close - ) - } - channel.stream.unchunks.concurrently(Stream.eval(doWalk)) - } - } - def createWatcher: Resource[F, Watcher[F]] = Watcher.default(this, F) def watch( diff --git a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala new file mode 100644 index 0000000000..d8989dcef0 --- /dev/null +++ b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2013 Functional Streams for Scala + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package fs2 +package io +package file + +import cats.effect.kernel.Sync +import cats.effect.std.Dispatcher + +import java.nio.file.{Files => JFiles, Path => JPath, _} +import java.nio.file.attribute.{BasicFileAttributes => JBasicFileAttributes} + +import fs2.concurrent.Channel +import fs2.io.CollectionCompat._ + +private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => + + override def walk( + start: Path, + maxDepth: Int, + followLinks: Boolean, + chunkSize: Int + ): Stream[F, Path] = + if (chunkSize == Int.MaxValue) walkEager(start, maxDepth, followLinks) + else walkLazy(start, maxDepth, followLinks, chunkSize) + + private def walkLazy( + start: Path, + maxDepth: Int, + followLinks: Boolean, + chunkSize: Int + ): Stream[F, Path] = + Stream.resource(Dispatcher.sequential[F]).flatMap { dispatcher => + Stream.eval(Channel.bounded[F, Chunk[Path]](10)).flatMap { channel => + val doWalk = Sync[F].interruptibleMany { + val bldr = Vector.newBuilder[Path] + var size = 0 + bldr.sizeHint(chunkSize) + JFiles.walkFileTree( + start.toNioPath, + if (followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, + maxDepth, + new SimpleFileVisitor[JPath] { + private def enqueue(path: JPath): FileVisitResult = { + bldr += Path.fromNioPath(path) + size += 1 + if (size >= chunkSize) { + val result = dispatcher.unsafeRunSync(channel.send(Chunk.from(bldr.result()))) + bldr.clear() + size = 0 + if (result.isRight) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE + } else FileVisitResult.CONTINUE + } + + override def visitFile(file: JPath, attrs: JBasicFileAttributes): FileVisitResult = + enqueue(file) + + override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = + FileVisitResult.CONTINUE + + override def preVisitDirectory(dir: JPath, attrs: JBasicFileAttributes) + : FileVisitResult = + enqueue(dir) + + override def postVisitDirectory(dir: JPath, t: IOException): FileVisitResult = + FileVisitResult.CONTINUE + } + ) + + dispatcher.unsafeRunSync( + if (size > 0) channel.closeWithElement(Chunk.from(bldr.result())) + else channel.close + ) + } + channel.stream.unchunks.concurrently(Stream.eval(doWalk)) + } + } + + private def walkEager(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = { + val doWalk = Sync[F].interruptibleMany { + val bldr = Vector.newBuilder[Path] + JFiles.walkFileTree( + start.toNioPath, + if (followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, + maxDepth, + new SimpleFileVisitor[JPath] { + private def enqueue(path: JPath): FileVisitResult = { + bldr += Path.fromNioPath(path) + FileVisitResult.CONTINUE + } + + override def visitFile(file: JPath, attrs: JBasicFileAttributes): FileVisitResult = + enqueue(file) + + override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = + FileVisitResult.CONTINUE + + override def preVisitDirectory(dir: JPath, attrs: JBasicFileAttributes): FileVisitResult = + enqueue(dir) + + override def postVisitDirectory(dir: JPath, t: IOException): FileVisitResult = + FileVisitResult.CONTINUE + } + ) + Chunk.from(bldr.result()) + } + Stream.eval(doWalk).flatMap(Stream.chunk) + } +} diff --git a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala index 1874ddd95d..973216517e 100644 --- a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala +++ b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala @@ -38,7 +38,7 @@ class WalkBenchmark extends Fs2IoSuite { file.mkdir() target = Path(file.toString) - val MaxDepth = 7 + val MaxDepth = 6 val Names = 'A'.to('E').toList.map(_.toString) def loop(cwd: File, depth: Int): Unit = @@ -73,9 +73,17 @@ class WalkBenchmark extends Fs2IoSuite { .count .unsafeRunSync() ) + val fs2EagerTime = time( + Files[IO] + .walkEager(target) + .compile + .count + .unsafeRunSync() + ) val nioTime = time(java.nio.file.Files.walk(target.toNioPath).count()) val epsilon = nioTime.toNanos * 1.5 println(s"fs2 took: ${fs2Time.toMillis} ms") + println(s"fs2 eager took: ${fs2EagerTime.toMillis} ms") println(s"nio took: ${nioTime.toMillis} ms") assert( (fs2Time - nioTime).toNanos.abs < epsilon, diff --git a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala new file mode 100644 index 0000000000..613b05726a --- /dev/null +++ b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2013 Functional Streams for Scala + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package fs2 +package io +package file + +private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => +} + + diff --git a/io/shared/src/main/scala/fs2/io/file/Files.scala b/io/shared/src/main/scala/fs2/io/file/Files.scala index 61f3bb1c36..a78895a78b 100644 --- a/io/shared/src/main/scala/fs2/io/file/Files.scala +++ b/io/shared/src/main/scala/fs2/io/file/Files.scala @@ -379,7 +379,20 @@ sealed trait Files[F[_]] extends FilesPlatform[F] { /** Creates a stream of paths contained in a given file tree down to a given depth. */ - def walk(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] + def walk(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = + walk(start, maxDepth, followLinks, 4096) + + /** Creates a stream of paths contained in a given file tree down to a given depth. + * The `chunkSize` parameter specifies the maximumn number of elements in a chunk emitted + * from the returned stream. Further, allows implementations to optimize traversal. + * A chunk size of `Int.MaxValue` allows implementations to eagerly collect all paths + * in the file tree and emit a single chunk. + */ + def walk(start: Path, maxDepth: Int, followLinks: Boolean, chunkSize: Int): Stream[F, Path] + + /** Eagerly walks the given file tree. Alias for `walk(start, Int.MaxValue, false, Int.MaxValue)`. */ + def walkEager(start: Path): Stream[F, Path] = + walk(start, Int.MaxValue, false, Int.MaxValue) /** Writes all data to the file at the specified path. * @@ -505,7 +518,7 @@ object Files extends FilesCompanionPlatform with FilesLowPriority { case _: NoSuchFileException => () }) - def walk(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = { + def walk(start: Path, maxDepth: Int, followLinks: Boolean, chunkSize: Int): Stream[F, Path] = { def go(start: Path, maxDepth: Int, ancestry: List[Either[Path, FileKey]]): Stream[F, Path] = Stream.emit(start) ++ { @@ -541,6 +554,8 @@ object Files extends FilesCompanionPlatform with FilesLowPriority { } Stream.eval(getBasicFileAttributes(start, followLinks)) >> go(start, maxDepth, Nil) + .chunkN(chunkSize) + .flatMap(Stream.chunk) } def writeAll( From 18107f4736c16a4dead4ff1352482ef0fa1c0d6b Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sun, 4 Feb 2024 15:00:39 -0500 Subject: [PATCH 04/43] Scalafmt --- .../src/main/scala/fs2/io/file/AsyncFilesPlatform.scala | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala index 613b05726a..d499577fe4 100644 --- a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -23,7 +23,4 @@ package fs2 package io package file -private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => -} - - +private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => } From 9a915eb71991afca8cd7f9b1f64c9c29e5f61f5d Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sun, 4 Feb 2024 15:01:32 -0500 Subject: [PATCH 05/43] Reduce channel bound --- io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala index d8989dcef0..37c7628326 100644 --- a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -50,7 +50,7 @@ private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => chunkSize: Int ): Stream[F, Path] = Stream.resource(Dispatcher.sequential[F]).flatMap { dispatcher => - Stream.eval(Channel.bounded[F, Chunk[Path]](10)).flatMap { channel => + Stream.eval(Channel.bounded[F, Chunk[Path]](2)).flatMap { channel => val doWalk = Sync[F].interruptibleMany { val bldr = Vector.newBuilder[Path] var size = 0 From a5bd763013b68adf9b9d032a43e4a96af54384ce Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sat, 10 Feb 2024 09:37:28 -0500 Subject: [PATCH 06/43] Switch to synchronous channel --- io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala index 37c7628326..de8367789d 100644 --- a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -50,7 +50,7 @@ private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => chunkSize: Int ): Stream[F, Path] = Stream.resource(Dispatcher.sequential[F]).flatMap { dispatcher => - Stream.eval(Channel.bounded[F, Chunk[Path]](2)).flatMap { channel => + Stream.eval(Channel.synchronous[F, Chunk[Path]]).flatMap { channel => val doWalk = Sync[F].interruptibleMany { val bldr = Vector.newBuilder[Path] var size = 0 From c7358a69fe53de72bc1252b6674ab980d3bcf87d Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sat, 10 Feb 2024 14:44:13 -0500 Subject: [PATCH 07/43] Switch to just in time implementation --- .../scala/fs2/io/file/FilesPlatform.scala | 141 +++++++++++++++++- .../fs2/io/file/AsyncFilesPlatform.scala | 128 ---------------- .../scala/fs2/io/file/WalkBenchmark.scala | 4 +- .../fs2/io/file/AsyncFilesPlatform.scala | 26 ---- 4 files changed, 142 insertions(+), 157 deletions(-) delete mode 100644 io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala delete mode 100644 io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala diff --git a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala index 5fa635e5f3..87ae39d238 100644 --- a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -39,6 +39,7 @@ import java.security.Principal import java.util.stream.{Stream => JStream} import scala.concurrent.duration._ +import scala.util.control.NonFatal import fs2.io.CollectionCompat._ @@ -91,8 +92,7 @@ private[file] trait FilesCompanionPlatform { private case class NioFileKey(value: AnyRef) extends FileKey private final class AsyncFiles[F[_]](protected implicit val F: Async[F]) - extends Files.UnsealedFiles[F] - with AsyncFilesPlatform[F] { + extends Files.UnsealedFiles[F] { def copy(source: Path, target: Path, flags: CopyFlags): F[Unit] = Sync[F].blocking { @@ -390,6 +390,143 @@ private[file] trait FilesCompanionPlatform { .resource(Resource.fromAutoCloseable(javaCollection)) .flatMap(ds => Stream.fromBlockingIterator[F](collectionIterator(ds), pathStreamChunkSize)) + override def walk( + start: Path, + maxDepth: Int, + followLinks: Boolean, + chunkSize: Int + ): Stream[F, Path] = + if (chunkSize == Int.MaxValue) walkEager(start, maxDepth, followLinks) + else walkJustInTime(start, maxDepth, followLinks, chunkSize) + + private def walkEager(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = { + val doWalk = Sync[F].interruptibleMany { + val bldr = Vector.newBuilder[Path] + JFiles.walkFileTree( + start.toNioPath, + if (followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, + maxDepth, + new SimpleFileVisitor[JPath] { + private def enqueue(path: JPath): FileVisitResult = { + bldr += Path.fromNioPath(path) + FileVisitResult.CONTINUE + } + + override def visitFile(file: JPath, attrs: JBasicFileAttributes): FileVisitResult = + enqueue(file) + + override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = + FileVisitResult.CONTINUE + + override def preVisitDirectory( + dir: JPath, + attrs: JBasicFileAttributes + ): FileVisitResult = + enqueue(dir) + + override def postVisitDirectory(dir: JPath, t: IOException): FileVisitResult = + FileVisitResult.CONTINUE + } + ) + Chunk.from(bldr.result()) + } + Stream.eval(doWalk).flatMap(Stream.chunk) + } + + private case class WalkEntry( + path: Path, + attr: JBasicFileAttributes, + depth: Int, + ancestry: List[Either[Path, NioFileKey]] + ) + + private def walkJustInTime( + start: Path, + maxDepth: Int, + followLinks: Boolean, + chunkSize: Int + ): Stream[F, Path] = { + import scala.collection.immutable.Queue + + def loop(toWalk0: Queue[WalkEntry]): Stream[F, Path] = { + val partialWalk = Sync[F].interruptibleMany { + var acc = Vector.empty[Path] + var toWalk = toWalk0 + + while (acc.size < chunkSize && toWalk.nonEmpty) { + val entry = toWalk.head + toWalk = toWalk.drop(1) + acc = acc :+ entry.path + if (entry.depth < maxDepth) { + val dir = + if (entry.attr.isDirectory) entry.path + else if (followLinks && entry.attr.isSymbolicLink) { + try { + val targetAttr = + JFiles.readAttributes(entry.path.toNioPath, classOf[JBasicFileAttributes]) + val fileKey = Option(targetAttr.fileKey) + val isCycle = entry.ancestry.exists { + case Right(ancestorKey) => fileKey.contains(ancestorKey) + case Left(ancestorPath) => + JFiles.isSameFile(entry.path.toNioPath, ancestorPath.toNioPath) + } + if (isCycle) throw new FileSystemLoopException(entry.path.toString) + else entry.path + } catch { + case NonFatal(_) => null + } + } else null + if (dir ne null) { + try { + val listing = JFiles.list(dir.toNioPath) + try { + val descendants = listing.iterator.asScala.flatMap { p => + try + Some( + WalkEntry( + Path.fromNioPath(p), + JFiles.readAttributes( + p, + classOf[JBasicFileAttributes], + LinkOption.NOFOLLOW_LINKS + ), + entry.depth + 1, + Option(entry.attr.fileKey) + .map(NioFileKey(_)) + .toRight(entry.path) :: entry.ancestry + ) + ) + catch { + case NonFatal(_) => None + } + } + toWalk = Queue.empty ++ descendants ++ toWalk + } finally listing.close() + } catch { + case NonFatal(_) => () + } + } + } + } + + Stream.chunk(Chunk.from(acc)) ++ (if (toWalk.isEmpty) Stream.empty else loop(toWalk)) + } + Stream.eval(partialWalk).flatten + } + + Stream + .eval(Sync[F].interruptibleMany { + WalkEntry( + start, + JFiles.readAttributes(start.toNioPath, classOf[JBasicFileAttributes]), + 1, + Nil + ) + }) + .mask + .flatMap(w => loop(Queue(w))) + } + def createWatcher: Resource[F, Watcher[F]] = Watcher.default(this, F) def watch( diff --git a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala deleted file mode 100644 index de8367789d..0000000000 --- a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (c) 2013 Functional Streams for Scala - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package fs2 -package io -package file - -import cats.effect.kernel.Sync -import cats.effect.std.Dispatcher - -import java.nio.file.{Files => JFiles, Path => JPath, _} -import java.nio.file.attribute.{BasicFileAttributes => JBasicFileAttributes} - -import fs2.concurrent.Channel -import fs2.io.CollectionCompat._ - -private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => - - override def walk( - start: Path, - maxDepth: Int, - followLinks: Boolean, - chunkSize: Int - ): Stream[F, Path] = - if (chunkSize == Int.MaxValue) walkEager(start, maxDepth, followLinks) - else walkLazy(start, maxDepth, followLinks, chunkSize) - - private def walkLazy( - start: Path, - maxDepth: Int, - followLinks: Boolean, - chunkSize: Int - ): Stream[F, Path] = - Stream.resource(Dispatcher.sequential[F]).flatMap { dispatcher => - Stream.eval(Channel.synchronous[F, Chunk[Path]]).flatMap { channel => - val doWalk = Sync[F].interruptibleMany { - val bldr = Vector.newBuilder[Path] - var size = 0 - bldr.sizeHint(chunkSize) - JFiles.walkFileTree( - start.toNioPath, - if (followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, - maxDepth, - new SimpleFileVisitor[JPath] { - private def enqueue(path: JPath): FileVisitResult = { - bldr += Path.fromNioPath(path) - size += 1 - if (size >= chunkSize) { - val result = dispatcher.unsafeRunSync(channel.send(Chunk.from(bldr.result()))) - bldr.clear() - size = 0 - if (result.isRight) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE - } else FileVisitResult.CONTINUE - } - - override def visitFile(file: JPath, attrs: JBasicFileAttributes): FileVisitResult = - enqueue(file) - - override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = - FileVisitResult.CONTINUE - - override def preVisitDirectory(dir: JPath, attrs: JBasicFileAttributes) - : FileVisitResult = - enqueue(dir) - - override def postVisitDirectory(dir: JPath, t: IOException): FileVisitResult = - FileVisitResult.CONTINUE - } - ) - - dispatcher.unsafeRunSync( - if (size > 0) channel.closeWithElement(Chunk.from(bldr.result())) - else channel.close - ) - } - channel.stream.unchunks.concurrently(Stream.eval(doWalk)) - } - } - - private def walkEager(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = { - val doWalk = Sync[F].interruptibleMany { - val bldr = Vector.newBuilder[Path] - JFiles.walkFileTree( - start.toNioPath, - if (followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, - maxDepth, - new SimpleFileVisitor[JPath] { - private def enqueue(path: JPath): FileVisitResult = { - bldr += Path.fromNioPath(path) - FileVisitResult.CONTINUE - } - - override def visitFile(file: JPath, attrs: JBasicFileAttributes): FileVisitResult = - enqueue(file) - - override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = - FileVisitResult.CONTINUE - - override def preVisitDirectory(dir: JPath, attrs: JBasicFileAttributes): FileVisitResult = - enqueue(dir) - - override def postVisitDirectory(dir: JPath, t: IOException): FileVisitResult = - FileVisitResult.CONTINUE - } - ) - Chunk.from(bldr.result()) - } - Stream.eval(doWalk).flatMap(Stream.chunk) - } -} diff --git a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala index 973216517e..168f975ee5 100644 --- a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala +++ b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala @@ -29,6 +29,8 @@ import scala.concurrent.duration.* class WalkBenchmark extends Fs2IoSuite { + override def munitIOTimeout = 5.minutes + private var target: Path = _ override def beforeAll() = { @@ -38,7 +40,7 @@ class WalkBenchmark extends Fs2IoSuite { file.mkdir() target = Path(file.toString) - val MaxDepth = 6 + val MaxDepth = 7 val Names = 'A'.to('E').toList.map(_.toString) def loop(cwd: File, depth: Int): Unit = diff --git a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala deleted file mode 100644 index d499577fe4..0000000000 --- a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2013 Functional Streams for Scala - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package fs2 -package io -package file - -private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => } From 09b95f8eecf16f9c1e9e1d38b156346fbe2f3252 Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sat, 10 Feb 2024 15:46:58 -0500 Subject: [PATCH 08/43] Add more tests --- .../scala/fs2/io/file/FilesPlatform.scala | 17 ++-- .../test/scala/fs2/io/file/FilesSuite.scala | 94 ++++++++++++++++++- 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala index 87ae39d238..74bfb7ab08 100644 --- a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -27,7 +27,7 @@ import cats.effect.kernel.{Async, Resource, Sync} import cats.syntax.all._ import java.nio.channels.{FileChannel, SeekableByteChannel} -import java.nio.file.{Files => JFiles, Path => JPath, _} +import java.nio.file.{Files => JFiles, Path => JPath, FileSystemLoopException => _, _} import java.nio.file.attribute.{ BasicFileAttributeView, BasicFileAttributes => JBasicFileAttributes, @@ -416,7 +416,10 @@ private[file] trait FilesCompanionPlatform { enqueue(file) override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = - FileVisitResult.CONTINUE + t match { + case _: FileSystemLoopException => throw t + case _ => FileVisitResult.CONTINUE + } override def preVisitDirectory( dir: JPath, @@ -464,16 +467,18 @@ private[file] trait FilesCompanionPlatform { try { val targetAttr = JFiles.readAttributes(entry.path.toNioPath, classOf[JBasicFileAttributes]) - val fileKey = Option(targetAttr.fileKey) + val fileKey = Option(targetAttr.fileKey).map(NioFileKey(_)) val isCycle = entry.ancestry.exists { - case Right(ancestorKey) => fileKey.contains(ancestorKey) + case Right(ancestorKey) => + fileKey.contains(ancestorKey) case Left(ancestorPath) => JFiles.isSameFile(entry.path.toNioPath, ancestorPath.toNioPath) } if (isCycle) throw new FileSystemLoopException(entry.path.toString) else entry.path } catch { - case NonFatal(_) => null + case t: FileSystemLoopException => throw t + case NonFatal(_) => null } } else null if (dir ne null) { @@ -519,7 +524,7 @@ private[file] trait FilesCompanionPlatform { WalkEntry( start, JFiles.readAttributes(start.toNioPath, classOf[JBasicFileAttributes]), - 1, + 0, Nil ) }) diff --git a/io/shared/src/test/scala/fs2/io/file/FilesSuite.scala b/io/shared/src/test/scala/fs2/io/file/FilesSuite.scala index bc0048b22b..028254ae00 100644 --- a/io/shared/src/test/scala/fs2/io/file/FilesSuite.scala +++ b/io/shared/src/test/scala/fs2/io/file/FilesSuite.scala @@ -568,10 +568,9 @@ class FilesSuite extends Fs2IoSuite with BaseFileSuite { Stream .resource(tempFilesHierarchy) .flatMap(topDir => Files[IO].walk(topDir)) - .map(_ => 1) .compile - .foldMonoid - .assertEquals(31) // the root + 5 children + 5 files per child directory + .count + .assertEquals(31L) // the root + 5 children + 5 files per child directory } test("can delete files in a nested tree") { @@ -591,6 +590,95 @@ class FilesSuite extends Fs2IoSuite with BaseFileSuite { .foldMonoid .assertEquals(25) } + + test("maxDepth = 0") { + Stream + .resource(tempFilesHierarchy) + .flatMap(topDir => Files[IO].walk(topDir, maxDepth = 0, followLinks = false)) + .compile + .count + .assertEquals(1L) // the root + } + + test("maxDepth = 1") { + Stream + .resource(tempFilesHierarchy) + .flatMap(topDir => Files[IO].walk(topDir, maxDepth = 1, followLinks = false)) + .compile + .count + .assertEquals(6L) // the root + 5 children + } + + test("maxDepth = 1 / eager") { + Stream + .resource(tempFilesHierarchy) + .flatMap(topDir => + Files[IO].walk(topDir, maxDepth = 1, followLinks = false, chunkSize = Int.MaxValue) + ) + .compile + .count + .assertEquals(6L) // the root + 5 children + } + + test("maxDepth = 2") { + Stream + .resource(tempFilesHierarchy) + .flatMap(topDir => Files[IO].walk(topDir, maxDepth = 2, followLinks = false)) + .compile + .count + .assertEquals(31L) // the root + 5 children + 5 files per child directory + } + + test("followLinks = true") { + Stream + .resource((tempFilesHierarchy, tempFilesHierarchy).tupled) + .evalMap { case (topDir, secondDir) => + Files[IO].createSymbolicLink(topDir / "link", secondDir).as(topDir) + } + .flatMap(topDir => Files[IO].walk(topDir, maxDepth = Int.MaxValue, followLinks = true)) + .compile + .count + .assertEquals(31L * 2) + } + + test("followLinks = false") { + Stream + .resource((tempFilesHierarchy, tempFilesHierarchy).tupled) + .evalMap { case (topDir, secondDir) => + Files[IO].createSymbolicLink(topDir / "link", secondDir).as(topDir) + } + .flatMap(topDir => Files[IO].walk(topDir, maxDepth = Int.MaxValue, followLinks = false)) + .compile + .count + .assertEquals(32L) + } + + test("followLinks with cycle") { + Stream + .resource(tempFilesHierarchy) + .evalTap { topDir => + Files[IO].createSymbolicLink(topDir / "link", topDir) + } + .flatMap(topDir => Files[IO].walk(topDir, maxDepth = Int.MaxValue, followLinks = true)) + .compile + .count + .intercept[FileSystemLoopException] + } + + test("followLinks with cycle / eager") { + Stream + .resource(tempFilesHierarchy) + .evalTap { topDir => + Files[IO].createSymbolicLink(topDir / "link", topDir) + } + .flatMap(topDir => + Files[IO] + .walk(topDir, maxDepth = Int.MaxValue, followLinks = true, chunkSize = Int.MaxValue) + ) + .compile + .count + .intercept[FileSystemLoopException] + } } test("writeRotate") { From 73be7b941b4897c071da52453dbbd051ae3176cf Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sat, 10 Feb 2024 16:19:51 -0500 Subject: [PATCH 09/43] Make test cleanup more lenient --- .../src/test/scala/fs2/io/file/BaseFileSuite.scala | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/io/jvm-native/src/test/scala/fs2/io/file/BaseFileSuite.scala b/io/jvm-native/src/test/scala/fs2/io/file/BaseFileSuite.scala index d67248ed66..559328084d 100644 --- a/io/jvm-native/src/test/scala/fs2/io/file/BaseFileSuite.scala +++ b/io/jvm-native/src/test/scala/fs2/io/file/BaseFileSuite.scala @@ -30,6 +30,7 @@ import java.nio.file.{Files => JFiles, Path => JPath, _} import java.nio.file.attribute.{BasicFileAttributes => JBasicFileAttributes} import scala.concurrent.duration._ +import scala.util.control.NonFatal trait BaseFileSuite extends Fs2Suite { @@ -77,11 +78,15 @@ trait BaseFileSuite extends Fs2Suite { dir.toNioPath, new SimpleFileVisitor[JPath] { override def visitFile(path: JPath, attrs: JBasicFileAttributes) = { - JFiles.delete(path) + try JFiles.deleteIfExists(path) + catch { case NonFatal(_) => () } FileVisitResult.CONTINUE } + override def visitFileFailed(path: JPath, e: IOException) = + FileVisitResult.CONTINUE override def postVisitDirectory(path: JPath, e: IOException) = { - JFiles.delete(path) + try JFiles.deleteIfExists(path) + catch { case NonFatal(_) => () } FileVisitResult.CONTINUE } } From 64987f7690631badd9ed453dec4a2593bfe054cb Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sat, 10 Feb 2024 17:45:47 -0500 Subject: [PATCH 10/43] Disable eager walks on Scala Native --- .../scala/fs2/io/file/FilesPlatform.scala | 16 ++----- .../fs2/io/file/AsyncFilesPlatform.scala | 45 +++++++++++++++++++ .../fs2/io/file/AsyncFilesPlatform.scala | 42 +++++++++++++++++ 3 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala create mode 100644 io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala diff --git a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala index 74bfb7ab08..a62a292021 100644 --- a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -92,7 +92,8 @@ private[file] trait FilesCompanionPlatform { private case class NioFileKey(value: AnyRef) extends FileKey private final class AsyncFiles[F[_]](protected implicit val F: Async[F]) - extends Files.UnsealedFiles[F] { + extends Files.UnsealedFiles[F] + with AsyncFilesPlatform[F] { def copy(source: Path, target: Path, flags: CopyFlags): F[Unit] = Sync[F].blocking { @@ -390,16 +391,7 @@ private[file] trait FilesCompanionPlatform { .resource(Resource.fromAutoCloseable(javaCollection)) .flatMap(ds => Stream.fromBlockingIterator[F](collectionIterator(ds), pathStreamChunkSize)) - override def walk( - start: Path, - maxDepth: Int, - followLinks: Boolean, - chunkSize: Int - ): Stream[F, Path] = - if (chunkSize == Int.MaxValue) walkEager(start, maxDepth, followLinks) - else walkJustInTime(start, maxDepth, followLinks, chunkSize) - - private def walkEager(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = { + protected def walkEager(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = { val doWalk = Sync[F].interruptibleMany { val bldr = Vector.newBuilder[Path] JFiles.walkFileTree( @@ -443,7 +435,7 @@ private[file] trait FilesCompanionPlatform { ancestry: List[Either[Path, NioFileKey]] ) - private def walkJustInTime( + protected def walkJustInTime( start: Path, maxDepth: Int, followLinks: Boolean, diff --git a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala new file mode 100644 index 0000000000..39919a7c9d --- /dev/null +++ b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2013 Functional Streams for Scala + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package fs2 +package io +package file + +private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => + + override def walk( + start: Path, + maxDepth: Int, + followLinks: Boolean, + chunkSize: Int + ): Stream[F, Path] = + if (chunkSize == Int.MaxValue) walkEager(start, maxDepth, followLinks) + else walkJustInTime(start, maxDepth, followLinks, chunkSize) + + protected def walkEager(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] + + protected def walkJustInTime( + start: Path, + maxDepth: Int, + followLinks: Boolean, + chunkSize: Int + ): Stream[F, Path] +} diff --git a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala new file mode 100644 index 0000000000..14cd9e092a --- /dev/null +++ b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2013 Functional Streams for Scala + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package fs2 +package io +package file + +private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => + override def walk( + start: Path, + maxDepth: Int, + followLinks: Boolean, + chunkSize: Int + ): Stream[F, Path] = + // Disable eager walks until https://github.com/scala-native/scala-native/issues/3744 + walkJustInTime(start, maxDepth, followLinks, chunkSize) + + protected def walkJustInTime( + start: Path, + maxDepth: Int, + followLinks: Boolean, + chunkSize: Int + ): Stream[F, Path] +} From 6b171f5873f9687f079cf0ad606f87949aabc25e Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sun, 11 Feb 2024 10:06:09 -0500 Subject: [PATCH 11/43] Update walk api to take an options parameter and add support for allowing cycles while following links --- build.sbt | 2 +- .../scala/fs2/io/file/FilesPlatform.scala | 2 +- .../scala/fs2/io/file/FilesPlatform.scala | 25 ++--- .../fs2/io/file/AsyncFilesPlatform.scala | 14 +-- .../scala/fs2/io/file/WalkBenchmark.scala | 2 +- .../fs2/io/file/AsyncFilesPlatform.scala | 10 +- .../src/main/scala/fs2/io/file/Files.scala | 39 ++++---- .../main/scala/fs2/io/file/WalkOptions.scala | 92 +++++++++++++++++++ .../test/scala/fs2/io/file/FilesSuite.scala | 47 ++++++++-- 9 files changed, 175 insertions(+), 58 deletions(-) create mode 100644 io/shared/src/main/scala/fs2/io/file/WalkOptions.scala diff --git a/build.sbt b/build.sbt index 019c8b2202..0022e6a4d7 100644 --- a/build.sbt +++ b/build.sbt @@ -2,7 +2,7 @@ import com.typesafe.tools.mima.core._ Global / onChangedBuildSource := ReloadOnSourceChanges -ThisBuild / tlBaseVersion := "3.9" +ThisBuild / tlBaseVersion := "3.10" ThisBuild / organization := "co.fs2" ThisBuild / organizationName := "Functional Streams for Scala" diff --git a/io/js/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/js/src/main/scala/fs2/io/file/FilesPlatform.scala index 11938f6687..f895046adb 100644 --- a/io/js/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/js/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -175,7 +175,7 @@ private[fs2] trait FilesCompanionPlatform { ) ).adaptError { case IOException(ex) => ex } else - walk(path, Int.MaxValue, true).evalTap(deleteIfExists).compile.drain + walk(path, WalkOptions.Default.withFollowLinks(true)).evalTap(deleteIfExists).compile.drain override def exists(path: Path, followLinks: Boolean): F[Boolean] = (if (followLinks) diff --git a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala index a62a292021..839ed0ec91 100644 --- a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -391,13 +391,13 @@ private[file] trait FilesCompanionPlatform { .resource(Resource.fromAutoCloseable(javaCollection)) .flatMap(ds => Stream.fromBlockingIterator[F](collectionIterator(ds), pathStreamChunkSize)) - protected def walkEager(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = { + protected def walkEager(start: Path, options: WalkOptions): Stream[F, Path] = { val doWalk = Sync[F].interruptibleMany { val bldr = Vector.newBuilder[Path] JFiles.walkFileTree( start.toNioPath, - if (followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, - maxDepth, + if (options.followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, + options.maxDepth, new SimpleFileVisitor[JPath] { private def enqueue(path: JPath): FileVisitResult = { bldr += Path.fromNioPath(path) @@ -409,8 +409,9 @@ private[file] trait FilesCompanionPlatform { override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = t match { - case _: FileSystemLoopException => throw t - case _ => FileVisitResult.CONTINUE + case _: FileSystemLoopException => + if (options.allowCycles) enqueue(file) else throw t + case _ => FileVisitResult.CONTINUE } override def preVisitDirectory( @@ -437,9 +438,7 @@ private[file] trait FilesCompanionPlatform { protected def walkJustInTime( start: Path, - maxDepth: Int, - followLinks: Boolean, - chunkSize: Int + options: WalkOptions ): Stream[F, Path] = { import scala.collection.immutable.Queue @@ -448,14 +447,14 @@ private[file] trait FilesCompanionPlatform { var acc = Vector.empty[Path] var toWalk = toWalk0 - while (acc.size < chunkSize && toWalk.nonEmpty) { + while (acc.size < options.chunkSize && toWalk.nonEmpty) { val entry = toWalk.head toWalk = toWalk.drop(1) acc = acc :+ entry.path - if (entry.depth < maxDepth) { + if (entry.depth < options.maxDepth) { val dir = if (entry.attr.isDirectory) entry.path - else if (followLinks && entry.attr.isSymbolicLink) { + else if (options.followLinks && entry.attr.isSymbolicLink) { try { val targetAttr = JFiles.readAttributes(entry.path.toNioPath, classOf[JBasicFileAttributes]) @@ -466,7 +465,9 @@ private[file] trait FilesCompanionPlatform { case Left(ancestorPath) => JFiles.isSameFile(entry.path.toNioPath, ancestorPath.toNioPath) } - if (isCycle) throw new FileSystemLoopException(entry.path.toString) + if (isCycle) + if (options.allowCycles) null + else throw new FileSystemLoopException(entry.path.toString) else entry.path } catch { case t: FileSystemLoopException => throw t diff --git a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala index 39919a7c9d..894a03e91d 100644 --- a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -27,19 +27,15 @@ private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => override def walk( start: Path, - maxDepth: Int, - followLinks: Boolean, - chunkSize: Int + options: WalkOptions ): Stream[F, Path] = - if (chunkSize == Int.MaxValue) walkEager(start, maxDepth, followLinks) - else walkJustInTime(start, maxDepth, followLinks, chunkSize) + if (options.chunkSize == Int.MaxValue) walkEager(start, options) + else walkJustInTime(start, options) - protected def walkEager(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] + protected def walkEager(start: Path, options: WalkOptions): Stream[F, Path] protected def walkJustInTime( start: Path, - maxDepth: Int, - followLinks: Boolean, - chunkSize: Int + options: WalkOptions ): Stream[F, Path] } diff --git a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala index 168f975ee5..d6a0b5df58 100644 --- a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala +++ b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala @@ -77,7 +77,7 @@ class WalkBenchmark extends Fs2IoSuite { ) val fs2EagerTime = time( Files[IO] - .walkEager(target) + .walk(target, WalkOptions.Eager) .compile .count .unsafeRunSync() diff --git a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala index 14cd9e092a..b5acbbe6af 100644 --- a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -26,17 +26,13 @@ package file private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => override def walk( start: Path, - maxDepth: Int, - followLinks: Boolean, - chunkSize: Int + options: WalkOptions ): Stream[F, Path] = // Disable eager walks until https://github.com/scala-native/scala-native/issues/3744 - walkJustInTime(start, maxDepth, followLinks, chunkSize) + walkJustInTime(start, options) protected def walkJustInTime( start: Path, - maxDepth: Int, - followLinks: Boolean, - chunkSize: Int + options: WalkOptions ): Stream[F, Path] } diff --git a/io/shared/src/main/scala/fs2/io/file/Files.scala b/io/shared/src/main/scala/fs2/io/file/Files.scala index a78895a78b..0f1f3ae2c0 100644 --- a/io/shared/src/main/scala/fs2/io/file/Files.scala +++ b/io/shared/src/main/scala/fs2/io/file/Files.scala @@ -375,24 +375,23 @@ sealed trait Files[F[_]] extends FilesPlatform[F] { /** Creates a stream of paths contained in a given file tree. Depth is unlimited. */ def walk(start: Path): Stream[F, Path] = - walk(start, Int.MaxValue, false) + walk(start, WalkOptions.Default) - /** Creates a stream of paths contained in a given file tree down to a given depth. + /** Creates a stream of paths contained in a given file tree. + * + * The `options` parameter allows for customizing the walk behavior. The `WalkOptions` + * type provides both `WalkOptions.Default` and `WalkOptions.Eager` as starting points, + * and further customizations can be specified via methods on the returned options value. + * For example, to eagerly walk a directory while following symbolic links, emitting all + * paths as a single chunk, use `walk(start, WalkOptions.Eager.withFollowLinks(true))`. */ - def walk(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = - walk(start, maxDepth, followLinks, 4096) + def walk(start: Path, options: WalkOptions): Stream[F, Path] /** Creates a stream of paths contained in a given file tree down to a given depth. - * The `chunkSize` parameter specifies the maximumn number of elements in a chunk emitted - * from the returned stream. Further, allows implementations to optimize traversal. - * A chunk size of `Int.MaxValue` allows implementations to eagerly collect all paths - * in the file tree and emit a single chunk. */ - def walk(start: Path, maxDepth: Int, followLinks: Boolean, chunkSize: Int): Stream[F, Path] - - /** Eagerly walks the given file tree. Alias for `walk(start, Int.MaxValue, false, Int.MaxValue)`. */ - def walkEager(start: Path): Stream[F, Path] = - walk(start, Int.MaxValue, false, Int.MaxValue) + @deprecated("Use walk(start, WalkOptions.Default.withMaxDepth(..).withFollowLinks(..))", "3.10") + def walk(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = + walk(start, WalkOptions.Default) /** Writes all data to the file at the specified path. * @@ -518,7 +517,7 @@ object Files extends FilesCompanionPlatform with FilesLowPriority { case _: NoSuchFileException => () }) - def walk(start: Path, maxDepth: Int, followLinks: Boolean, chunkSize: Int): Stream[F, Path] = { + def walk(start: Path, options: WalkOptions): Stream[F, Path] = { def go(start: Path, maxDepth: Int, ancestry: List[Either[Path, FileKey]]): Stream[F, Path] = Stream.emit(start) ++ { @@ -529,7 +528,7 @@ object Files extends FilesCompanionPlatform with FilesLowPriority { list(start).mask.flatMap { path => go(path, maxDepth - 1, attr.fileKey.toRight(start) :: ancestry) } - else if (attr.isSymbolicLink && followLinks) + else if (attr.isSymbolicLink && options.followLinks) Stream.eval(getBasicFileAttributes(start, followLinks = true)).mask.flatMap { attr => val fileKey = attr.fileKey @@ -543,6 +542,8 @@ object Files extends FilesCompanionPlatform with FilesLowPriority { list(start).mask.flatMap { path => go(path, maxDepth - 1, attr.fileKey.toRight(start) :: ancestry) } + else if (options.allowCycles) + Stream.empty else Stream.raiseError(new FileSystemLoopException(start.toString)) } @@ -553,8 +554,12 @@ object Files extends FilesCompanionPlatform with FilesLowPriority { } } - Stream.eval(getBasicFileAttributes(start, followLinks)) >> go(start, maxDepth, Nil) - .chunkN(chunkSize) + Stream.eval(getBasicFileAttributes(start, options.followLinks)) >> go( + start, + options.maxDepth, + Nil + ) + .chunkN(options.chunkSize) .flatMap(Stream.chunk) } diff --git a/io/shared/src/main/scala/fs2/io/file/WalkOptions.scala b/io/shared/src/main/scala/fs2/io/file/WalkOptions.scala new file mode 100644 index 0000000000..1a05c28709 --- /dev/null +++ b/io/shared/src/main/scala/fs2/io/file/WalkOptions.scala @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2013 Functional Streams for Scala + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package fs2 +package io +package file + +/** Options that customize a filesystem walk via `Files[F].walk`. */ +sealed trait WalkOptions { + + /** Size of chunks emitted from the walk. + * + * Implementations *may* use this for optimization, batching file system operations. + * + * A chunk size of 1 hints to the implementation to use the maximally laziness in + * file system access, emitting a single path at a time. + * + * A chunk size of `Int.MaxValue` hints to the implementation to perform all file system + * operations at once, emitting a single chunk with all paths. + */ + def chunkSize: Int + + /** Maximum depth to walk. A value of 0 results in emitting just the starting path. + * A value of 1 results in emitting the starting path and all direct descendants. + */ + def maxDepth: Int + + /** Indicates whether links are followed during the walk. If false, the path of + * each link is emitted. If true, links are followed and their contents are emitted. + */ + def followLinks: Boolean + + /** Indicates whether to allow cycles when following links. If true, any link causing a + * cycle is emitted as the link path. If false, a cycle results in walk failing with a `FileSystemLoopException`. + */ + def allowCycles: Boolean + + /** Returns a new `WalkOptions` with the specified chunk size. */ + def withChunkSize(chunkSize: Int): WalkOptions + + /** Returns a new `WalkOptions` with the specified max depth. */ + def withMaxDepth(maxDepth: Int): WalkOptions + + /** Returns a new `WalkOptions` with the specified value for `followLinks`. */ + def withFollowLinks(value: Boolean): WalkOptions + + /** Returns a new `WalkOptions` with the specified value for `allowCycles`. */ + def withAllowCycles(value: Boolean): WalkOptions +} + +object WalkOptions { + private case class DefaultWalkOptions( + chunkSize: Int, + maxDepth: Int, + followLinks: Boolean, + allowCycles: Boolean + ) extends WalkOptions { + def withChunkSize(chunkSize: Int): WalkOptions = copy(chunkSize = chunkSize) + def withMaxDepth(maxDepth: Int): WalkOptions = copy(maxDepth = maxDepth) + def withFollowLinks(value: Boolean): WalkOptions = copy(followLinks = value) + def withAllowCycles(value: Boolean): WalkOptions = copy(allowCycles = value) + override def toString = + s"WalkOptions(chunkSize = $chunkSize, maxDepth = $maxDepth, followLinks = $followLinks, allowCycles = $allowCycles)" + } + + /** Default walk options, using a large chunk size, unlimited depth, and no link following. */ + val Default: WalkOptions = DefaultWalkOptions(4096, Int.MaxValue, false, false) + + /** Like `Default` but uses the maximum chunk size, hinting the implementation should perform all file system operations before emitting any paths. */ + val Eager: WalkOptions = Default.withChunkSize(Int.MaxValue) + + /** Like `Default` but uses the minimum chunk size, hinting the implementation should perform minumum number of file system operations before emitting each path. */ + val Lazy: WalkOptions = Default.withChunkSize(1) +} diff --git a/io/shared/src/test/scala/fs2/io/file/FilesSuite.scala b/io/shared/src/test/scala/fs2/io/file/FilesSuite.scala index 028254ae00..bc1359373b 100644 --- a/io/shared/src/test/scala/fs2/io/file/FilesSuite.scala +++ b/io/shared/src/test/scala/fs2/io/file/FilesSuite.scala @@ -594,7 +594,7 @@ class FilesSuite extends Fs2IoSuite with BaseFileSuite { test("maxDepth = 0") { Stream .resource(tempFilesHierarchy) - .flatMap(topDir => Files[IO].walk(topDir, maxDepth = 0, followLinks = false)) + .flatMap(topDir => Files[IO].walk(topDir, WalkOptions.Default.withMaxDepth(0))) .compile .count .assertEquals(1L) // the root @@ -603,7 +603,7 @@ class FilesSuite extends Fs2IoSuite with BaseFileSuite { test("maxDepth = 1") { Stream .resource(tempFilesHierarchy) - .flatMap(topDir => Files[IO].walk(topDir, maxDepth = 1, followLinks = false)) + .flatMap(topDir => Files[IO].walk(topDir, WalkOptions.Default.withMaxDepth(1))) .compile .count .assertEquals(6L) // the root + 5 children @@ -612,9 +612,7 @@ class FilesSuite extends Fs2IoSuite with BaseFileSuite { test("maxDepth = 1 / eager") { Stream .resource(tempFilesHierarchy) - .flatMap(topDir => - Files[IO].walk(topDir, maxDepth = 1, followLinks = false, chunkSize = Int.MaxValue) - ) + .flatMap(topDir => Files[IO].walk(topDir, WalkOptions.Eager.withMaxDepth(1))) .compile .count .assertEquals(6L) // the root + 5 children @@ -623,7 +621,7 @@ class FilesSuite extends Fs2IoSuite with BaseFileSuite { test("maxDepth = 2") { Stream .resource(tempFilesHierarchy) - .flatMap(topDir => Files[IO].walk(topDir, maxDepth = 2, followLinks = false)) + .flatMap(topDir => Files[IO].walk(topDir, WalkOptions.Default.withMaxDepth(2))) .compile .count .assertEquals(31L) // the root + 5 children + 5 files per child directory @@ -635,7 +633,7 @@ class FilesSuite extends Fs2IoSuite with BaseFileSuite { .evalMap { case (topDir, secondDir) => Files[IO].createSymbolicLink(topDir / "link", secondDir).as(topDir) } - .flatMap(topDir => Files[IO].walk(topDir, maxDepth = Int.MaxValue, followLinks = true)) + .flatMap(topDir => Files[IO].walk(topDir, WalkOptions.Default.withFollowLinks(true))) .compile .count .assertEquals(31L * 2) @@ -647,7 +645,7 @@ class FilesSuite extends Fs2IoSuite with BaseFileSuite { .evalMap { case (topDir, secondDir) => Files[IO].createSymbolicLink(topDir / "link", secondDir).as(topDir) } - .flatMap(topDir => Files[IO].walk(topDir, maxDepth = Int.MaxValue, followLinks = false)) + .flatMap(topDir => Files[IO].walk(topDir, WalkOptions.Default)) .compile .count .assertEquals(32L) @@ -659,7 +657,7 @@ class FilesSuite extends Fs2IoSuite with BaseFileSuite { .evalTap { topDir => Files[IO].createSymbolicLink(topDir / "link", topDir) } - .flatMap(topDir => Files[IO].walk(topDir, maxDepth = Int.MaxValue, followLinks = true)) + .flatMap(topDir => Files[IO].walk(topDir, WalkOptions.Default.withFollowLinks(true))) .compile .count .intercept[FileSystemLoopException] @@ -673,12 +671,41 @@ class FilesSuite extends Fs2IoSuite with BaseFileSuite { } .flatMap(topDir => Files[IO] - .walk(topDir, maxDepth = Int.MaxValue, followLinks = true, chunkSize = Int.MaxValue) + .walk(topDir, WalkOptions.Eager.withFollowLinks(true)) ) .compile .count .intercept[FileSystemLoopException] } + + test("followLinks with cycle / cycles allowed") { + Stream + .resource(tempFilesHierarchy) + .evalTap { topDir => + Files[IO].createSymbolicLink(topDir / "link", topDir) + } + .flatMap(topDir => + Files[IO].walk(topDir, WalkOptions.Default.withFollowLinks(true).withAllowCycles(true)) + ) + .compile + .count + .assertEquals(32L) + } + + test("followLinks with cycle / eager / cycles allowed") { + Stream + .resource(tempFilesHierarchy) + .evalTap { topDir => + Files[IO].createSymbolicLink(topDir / "link", topDir) + } + .flatMap(topDir => + Files[IO] + .walk(topDir, WalkOptions.Eager.withFollowLinks(true).withAllowCycles(true)) + ) + .compile + .count + .assertEquals(32L) + } } test("writeRotate") { From e365b9a563c540c57de701fd9c3cf43ea2a4ab2d Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Tue, 13 Feb 2024 07:48:52 +0000 Subject: [PATCH 12/43] flake.lock: Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flake lock file updates: • Updated input 'typelevel-nix': 'github:typelevel/typelevel-nix/5c281f550dcb73eafe7341767d384bcdc3ec14fd' (2023-10-30) → 'github:typelevel/typelevel-nix/85318b2ace46ee59560d5a5ce1a56ee08b46b15e' (2024-02-12) • Updated input 'typelevel-nix/devshell': 'github:numtide/devshell/1aed986e3c81a4f6698e85a7452cbfcc4b31a36e' (2023-10-27) → 'github:numtide/devshell/83cb93d6d063ad290beee669f4badf9914cc16ec' (2024-01-15) • Added input 'typelevel-nix/devshell/flake-utils': 'github:numtide/flake-utils/4022d587cbbfd70fe950c1e2083a02621806a725' (2023-12-04) • Added input 'typelevel-nix/devshell/flake-utils/systems': 'github:nix-systems/default/da67096a3b9bf56a91d16901293e51ba5b49a27e' (2023-04-09) • Updated input 'typelevel-nix/devshell/nixpkgs': 'github:NixOS/nixpkgs/9952d6bc395f5841262b006fbace8dd7e143b634' (2023-02-26) → 'github:NixOS/nixpkgs/63143ac2c9186be6d9da6035fa22620018c85932' (2024-01-02) • Removed input 'typelevel-nix/devshell/systems' • Updated input 'typelevel-nix/flake-utils': 'github:numtide/flake-utils/ff7b65b44d01cf9ba6a71320833626af21126384' (2023-09-12) → 'github:numtide/flake-utils/1ef2e671c3b0c19053962c07dbda38332dcebf26' (2024-01-15) • Updated input 'typelevel-nix/nixpkgs': 'github:nixos/nixpkgs/90e85bc7c1a6fc0760a94ace129d3a1c61c3d035' (2023-10-29) → 'github:nixos/nixpkgs/f3a93440fbfff8a74350f4791332a19282cc6dc8' (2024-02-11) --- flake.lock | 54 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/flake.lock b/flake.lock index f60b5e27fc..105ffb7458 100644 --- a/flake.lock +++ b/flake.lock @@ -2,15 +2,15 @@ "nodes": { "devshell": { "inputs": { - "nixpkgs": "nixpkgs", - "systems": "systems" + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1698410321, - "narHash": "sha256-MphuSlgpmKwtJncGMohryHiK55J1n6WzVQ/OAfmfoMc=", + "lastModified": 1705332421, + "narHash": "sha256-USpGLPme1IuqG78JNqSaRabilwkCyHmVWY0M9vYyqEA=", "owner": "numtide", "repo": "devshell", - "rev": "1aed986e3c81a4f6698e85a7452cbfcc4b31a36e", + "rev": "83cb93d6d063ad290beee669f4badf9914cc16ec", "type": "github" }, "original": { @@ -20,15 +20,33 @@ } }, "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1701680307, + "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { "inputs": { "systems": "systems_2" }, "locked": { - "lastModified": 1694529238, - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", "owner": "numtide", "repo": "flake-utils", - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", "type": "github" }, "original": { @@ -39,11 +57,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1677383253, - "narHash": "sha256-UfpzWfSxkfXHnb4boXZNaKsAcUrZT9Hw+tao1oZxd08=", + "lastModified": 1704161960, + "narHash": "sha256-QGua89Pmq+FBAro8NriTuoO/wNaUtugt29/qqA8zeeM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9952d6bc395f5841262b006fbace8dd7e143b634", + "rev": "63143ac2c9186be6d9da6035fa22620018c85932", "type": "github" }, "original": { @@ -55,11 +73,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1698553279, - "narHash": "sha256-T/9P8yBSLcqo/v+FTOBK+0rjzjPMctVymZydbvR/Fak=", + "lastModified": 1707619277, + "narHash": "sha256-vKnYD5GMQbNQyyQm4wRlqi+5n0/F1hnvqSQgaBy4BqY=", "owner": "nixos", "repo": "nixpkgs", - "rev": "90e85bc7c1a6fc0760a94ace129d3a1c61c3d035", + "rev": "f3a93440fbfff8a74350f4791332a19282cc6dc8", "type": "github" }, "original": { @@ -115,15 +133,15 @@ "typelevel-nix": { "inputs": { "devshell": "devshell", - "flake-utils": "flake-utils", + "flake-utils": "flake-utils_2", "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1698638641, - "narHash": "sha256-piIhN6bnCGd3yVB4M17SOX/Ej9zq4Q4+KzzWZRRPaVs=", + "lastModified": 1707745269, + "narHash": "sha256-0mwnDOYtq6TNfKKOhMa66o4gDOZXTjY5kho43tTeQ24=", "owner": "typelevel", "repo": "typelevel-nix", - "rev": "5c281f550dcb73eafe7341767d384bcdc3ec14fd", + "rev": "85318b2ace46ee59560d5a5ce1a56ee08b46b15e", "type": "github" }, "original": { From 375942e1c6eebab8fb415a13bb2d3f3ac217e27d Mon Sep 17 00:00:00 2001 From: mpilquist Date: Tue, 13 Feb 2024 08:31:24 -0500 Subject: [PATCH 13/43] Fix interruption --- .../scala/fs2/io/file/FilesPlatform.scala | 14 +++++------ .../scala/fs2/io/file/WalkBenchmark.scala | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala index 839ed0ec91..2454ef4de1 100644 --- a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -392,7 +392,7 @@ private[file] trait FilesCompanionPlatform { .flatMap(ds => Stream.fromBlockingIterator[F](collectionIterator(ds), pathStreamChunkSize)) protected def walkEager(start: Path, options: WalkOptions): Stream[F, Path] = { - val doWalk = Sync[F].interruptibleMany { + val doWalk = Sync[F].interruptible { val bldr = Vector.newBuilder[Path] JFiles.walkFileTree( start.toNioPath, @@ -405,7 +405,7 @@ private[file] trait FilesCompanionPlatform { } override def visitFile(file: JPath, attrs: JBasicFileAttributes): FileVisitResult = - enqueue(file) + if (Thread.interrupted()) FileVisitResult.TERMINATE else enqueue(file) override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = t match { @@ -418,10 +418,10 @@ private[file] trait FilesCompanionPlatform { dir: JPath, attrs: JBasicFileAttributes ): FileVisitResult = - enqueue(dir) + if (Thread.interrupted()) FileVisitResult.TERMINATE else enqueue(dir) override def postVisitDirectory(dir: JPath, t: IOException): FileVisitResult = - FileVisitResult.CONTINUE + if (Thread.interrupted()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE } ) Chunk.from(bldr.result()) @@ -443,11 +443,11 @@ private[file] trait FilesCompanionPlatform { import scala.collection.immutable.Queue def loop(toWalk0: Queue[WalkEntry]): Stream[F, Path] = { - val partialWalk = Sync[F].interruptibleMany { + val partialWalk = Sync[F].interruptible { var acc = Vector.empty[Path] var toWalk = toWalk0 - while (acc.size < options.chunkSize && toWalk.nonEmpty) { + while (acc.size < options.chunkSize && toWalk.nonEmpty && !Thread.interrupted()) { val entry = toWalk.head toWalk = toWalk.drop(1) acc = acc :+ entry.path @@ -513,7 +513,7 @@ private[file] trait FilesCompanionPlatform { } Stream - .eval(Sync[F].interruptibleMany { + .eval(Sync[F].interruptible { WalkEntry( start, JFiles.readAttributes(start.toNioPath, classOf[JBasicFileAttributes]), diff --git a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala index d6a0b5df58..d6a8bb71ec 100644 --- a/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala +++ b/io/jvm/src/test/scala/fs2/io/file/WalkBenchmark.scala @@ -92,4 +92,28 @@ class WalkBenchmark extends Fs2IoSuite { s"fs2 time: $fs2Time, nio time: $nioTime, diff: ${fs2Time - nioTime}" ) } + + test("walk is interruptible") { + val elapsed = time( + Files[IO] + .walk(target) + .interruptAfter(1.second) + .compile + .count + .unsafeRunSync() + ) + assert(elapsed < 1250.milliseconds) + } + + test("walk eager is interruptible") { + val elapsed = time( + Files[IO] + .walk(target, WalkOptions.Eager) + .interruptAfter(1.second) + .compile + .count + .unsafeRunSync() + ) + assert(elapsed < 1250.milliseconds) + } } From 9ac90e2e2e050c846e7c35b6480d70dabb4233eb Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Wed, 14 Feb 2024 20:05:30 +0000 Subject: [PATCH 14/43] Update jnr-unixsocket to 0.38.22 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 189f7a20cd..9f1fbb6a33 100644 --- a/build.sbt +++ b/build.sbt @@ -338,7 +338,7 @@ lazy val io = crossProject(JVMPlatform, JSPlatform, NativePlatform) .jvmSettings( Test / fork := true, libraryDependencies ++= Seq( - "com.github.jnr" % "jnr-unixsocket" % "0.38.21" % Optional, + "com.github.jnr" % "jnr-unixsocket" % "0.38.22" % Optional, "com.google.jimfs" % "jimfs" % "1.3.0" % Test ) ) From cdcb952279e18e7c825a8a99edc8ce8b16fd5352 Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sat, 17 Feb 2024 11:19:52 -0500 Subject: [PATCH 15/43] Add Files.walkWithAttributes --- .../scala/fs2/io/file/FilesPlatform.scala | 31 +++++--- .../fs2/io/file/AsyncFilesPlatform.scala | 8 +- .../src/main/scala/fs2/io/file/Files.scala | 74 +++++++++++-------- .../src/main/scala/fs2/io/file/PathInfo.scala | 25 +++++++ 4 files changed, 91 insertions(+), 47 deletions(-) create mode 100644 io/shared/src/main/scala/fs2/io/file/PathInfo.scala diff --git a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala index 2454ef4de1..7b3e6c2f82 100644 --- a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -391,26 +391,35 @@ private[file] trait FilesCompanionPlatform { .resource(Resource.fromAutoCloseable(javaCollection)) .flatMap(ds => Stream.fromBlockingIterator[F](collectionIterator(ds), pathStreamChunkSize)) - protected def walkEager(start: Path, options: WalkOptions): Stream[F, Path] = { + protected def walkEager(start: Path, options: WalkOptions): Stream[F, PathInfo] = { val doWalk = Sync[F].interruptible { - val bldr = Vector.newBuilder[Path] + val bldr = Vector.newBuilder[PathInfo] JFiles.walkFileTree( start.toNioPath, if (options.followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, options.maxDepth, new SimpleFileVisitor[JPath] { - private def enqueue(path: JPath): FileVisitResult = { - bldr += Path.fromNioPath(path) + private def enqueue(path: JPath, attrs: JBasicFileAttributes): FileVisitResult = { + bldr += PathInfo(Path.fromNioPath(path), new DelegatingBasicFileAttributes(attrs)) FileVisitResult.CONTINUE } override def visitFile(file: JPath, attrs: JBasicFileAttributes): FileVisitResult = - if (Thread.interrupted()) FileVisitResult.TERMINATE else enqueue(file) + if (Thread.interrupted()) FileVisitResult.TERMINATE else enqueue(file, attrs) override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = t match { case _: FileSystemLoopException => - if (options.allowCycles) enqueue(file) else throw t + if (options.allowCycles) + enqueue( + file, + JFiles.readAttributes( + file, + classOf[JBasicFileAttributes], + LinkOption.NOFOLLOW_LINKS + ) + ) + else throw t case _ => FileVisitResult.CONTINUE } @@ -418,7 +427,7 @@ private[file] trait FilesCompanionPlatform { dir: JPath, attrs: JBasicFileAttributes ): FileVisitResult = - if (Thread.interrupted()) FileVisitResult.TERMINATE else enqueue(dir) + if (Thread.interrupted()) FileVisitResult.TERMINATE else enqueue(dir, attrs) override def postVisitDirectory(dir: JPath, t: IOException): FileVisitResult = if (Thread.interrupted()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE @@ -439,18 +448,18 @@ private[file] trait FilesCompanionPlatform { protected def walkJustInTime( start: Path, options: WalkOptions - ): Stream[F, Path] = { + ): Stream[F, PathInfo] = { import scala.collection.immutable.Queue - def loop(toWalk0: Queue[WalkEntry]): Stream[F, Path] = { + def loop(toWalk0: Queue[WalkEntry]): Stream[F, PathInfo] = { val partialWalk = Sync[F].interruptible { - var acc = Vector.empty[Path] + var acc = Vector.empty[PathInfo] var toWalk = toWalk0 while (acc.size < options.chunkSize && toWalk.nonEmpty && !Thread.interrupted()) { val entry = toWalk.head toWalk = toWalk.drop(1) - acc = acc :+ entry.path + acc = acc :+ PathInfo(entry.path, new DelegatingBasicFileAttributes(entry.attr)) if (entry.depth < options.maxDepth) { val dir = if (entry.attr.isDirectory) entry.path diff --git a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala index 894a03e91d..46b1be40fb 100644 --- a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -25,17 +25,17 @@ package file private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => - override def walk( + override def walkWithAttributes( start: Path, options: WalkOptions - ): Stream[F, Path] = + ): Stream[F, PathInfo] = if (options.chunkSize == Int.MaxValue) walkEager(start, options) else walkJustInTime(start, options) - protected def walkEager(start: Path, options: WalkOptions): Stream[F, Path] + protected def walkEager(start: Path, options: WalkOptions): Stream[F, PathInfo] protected def walkJustInTime( start: Path, options: WalkOptions - ): Stream[F, Path] + ): Stream[F, PathInfo] } diff --git a/io/shared/src/main/scala/fs2/io/file/Files.scala b/io/shared/src/main/scala/fs2/io/file/Files.scala index 0f1f3ae2c0..b4f4c0fc9b 100644 --- a/io/shared/src/main/scala/fs2/io/file/Files.scala +++ b/io/shared/src/main/scala/fs2/io/file/Files.scala @@ -385,7 +385,8 @@ sealed trait Files[F[_]] extends FilesPlatform[F] { * For example, to eagerly walk a directory while following symbolic links, emitting all * paths as a single chunk, use `walk(start, WalkOptions.Eager.withFollowLinks(true))`. */ - def walk(start: Path, options: WalkOptions): Stream[F, Path] + def walk(start: Path, options: WalkOptions): Stream[F, Path] = + walkWithAttributes(start, options).map(_.path) /** Creates a stream of paths contained in a given file tree down to a given depth. */ @@ -393,6 +394,13 @@ sealed trait Files[F[_]] extends FilesPlatform[F] { def walk(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = walk(start, WalkOptions.Default) + /** Like `walk` but returns a `PathInfo`, which provides both the `Path` and `BasicFileAttributes`. */ + def walkWithAttributes(start: Path): Stream[F, PathInfo] = + walkWithAttributes(start, WalkOptions.Default) + + /** Like `walk` but returns a `PathInfo`, which provides both the `Path` and `BasicFileAttributes`. */ + def walkWithAttributes(start: Path, options: WalkOptions): Stream[F, PathInfo] + /** Writes all data to the file at the specified path. * * The file is created if it does not exist and is truncated. @@ -517,44 +525,46 @@ object Files extends FilesCompanionPlatform with FilesLowPriority { case _: NoSuchFileException => () }) - def walk(start: Path, options: WalkOptions): Stream[F, Path] = { + def walkWithAttributes(start: Path, options: WalkOptions): Stream[F, PathInfo] = { - def go(start: Path, maxDepth: Int, ancestry: List[Either[Path, FileKey]]): Stream[F, Path] = - Stream.emit(start) ++ { - if (maxDepth == 0) Stream.empty - else - Stream.eval(getBasicFileAttributes(start, followLinks = false)).mask.flatMap { attr => - if (attr.isDirectory) - list(start).mask.flatMap { path => - go(path, maxDepth - 1, attr.fileKey.toRight(start) :: ancestry) + def go( + start: Path, + maxDepth: Int, + ancestry: List[Either[Path, FileKey]] + ): Stream[F, PathInfo] = + Stream.eval(getBasicFileAttributes(start, followLinks = false)).mask.flatMap { attr => + Stream.emit(PathInfo(start, attr)) ++ { + if (maxDepth == 0) Stream.empty + else if (attr.isDirectory) + list(start).mask.flatMap { path => + go(path, maxDepth - 1, attr.fileKey.toRight(start) :: ancestry) + } + else if (attr.isSymbolicLink && options.followLinks) + Stream.eval(getBasicFileAttributes(start, followLinks = true)).mask.flatMap { attr => + val fileKey = attr.fileKey + val isCycle = Traverse[List].existsM(ancestry) { + case Right(ancestorKey) => F.pure(fileKey.contains(ancestorKey)) + case Left(ancestorPath) => isSameFile(start, ancestorPath) } - else if (attr.isSymbolicLink && options.followLinks) - Stream.eval(getBasicFileAttributes(start, followLinks = true)).mask.flatMap { - attr => - val fileKey = attr.fileKey - val isCycle = Traverse[List].existsM(ancestry) { - case Right(ancestorKey) => F.pure(fileKey.contains(ancestorKey)) - case Left(ancestorPath) => isSameFile(start, ancestorPath) - } - Stream.eval(isCycle).flatMap { isCycle => - if (!isCycle) - list(start).mask.flatMap { path => - go(path, maxDepth - 1, attr.fileKey.toRight(start) :: ancestry) - } - else if (options.allowCycles) - Stream.empty - else - Stream.raiseError(new FileSystemLoopException(start.toString)) + Stream.eval(isCycle).flatMap { isCycle => + if (!isCycle) + list(start).mask.flatMap { path => + go(path, maxDepth - 1, attr.fileKey.toRight(start) :: ancestry) } - + else if (options.allowCycles) + Stream.empty + else + Stream.raiseError(new FileSystemLoopException(start.toString)) } - else - Stream.empty - } + + } + else + Stream.empty + } } - Stream.eval(getBasicFileAttributes(start, options.followLinks)) >> go( + go( start, options.maxDepth, Nil diff --git a/io/shared/src/main/scala/fs2/io/file/PathInfo.scala b/io/shared/src/main/scala/fs2/io/file/PathInfo.scala new file mode 100644 index 0000000000..fe42791aa0 --- /dev/null +++ b/io/shared/src/main/scala/fs2/io/file/PathInfo.scala @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2013 Functional Streams for Scala + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package fs2.io.file + +/** Provides a `Path` and its associated `BasicFileAttributes`. */ +case class PathInfo(path: Path, attributes: BasicFileAttributes) From 30e09a7b11237cef5fbdf15e8221174ad31194fd Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sat, 17 Feb 2024 11:25:08 -0500 Subject: [PATCH 16/43] Fix native compilation --- .../src/main/scala/fs2/io/file/AsyncFilesPlatform.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala index b5acbbe6af..87796c2134 100644 --- a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala @@ -24,15 +24,15 @@ package io package file private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => - override def walk( + override def walkWithAttributes( start: Path, options: WalkOptions - ): Stream[F, Path] = + ): Stream[F, PathInfo] = // Disable eager walks until https://github.com/scala-native/scala-native/issues/3744 walkJustInTime(start, options) protected def walkJustInTime( start: Path, options: WalkOptions - ): Stream[F, Path] + ): Stream[F, PathInfo] } From 26fed36d59411899038486236751e011924bde02 Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Sun, 18 Feb 2024 04:16:49 +0000 Subject: [PATCH 17/43] Update sbt-typelevel, sbt-typelevel-site to 0.6.6 --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 5ecb9404d4..65c7352d68 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,4 +1,4 @@ -val sbtTypelevelVersion = "0.6.5" +val sbtTypelevelVersion = "0.6.6" addSbtPlugin("org.typelevel" % "sbt-typelevel" % sbtTypelevelVersion) addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % sbtTypelevelVersion) addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.15.0") From aa566a3dfd15a8cf545e58d5c7feba2c94882cb4 Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sun, 18 Feb 2024 17:25:25 -0500 Subject: [PATCH 18/43] Fix performance of Stream.from(Blocking)Iterator when using large chunk sizes --- core/shared/src/main/scala/fs2/Stream.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/shared/src/main/scala/fs2/Stream.scala b/core/shared/src/main/scala/fs2/Stream.scala index b4c69671fa..4ceaff897c 100644 --- a/core/shared/src/main/scala/fs2/Stream.scala +++ b/core/shared/src/main/scala/fs2/Stream.scala @@ -3605,7 +3605,7 @@ object Stream extends StreamLowPriority { def getNextChunk(i: Iterator[A]): F[Option[(Chunk[A], Iterator[A])]] = F.suspend(hint) { - for (_ <- 1 to chunkSize if i.hasNext) yield i.next() + i.take(chunkSize).toVector }.map { s => if (s.isEmpty) None else Some((Chunk.from(s), i)) } From 504a8f3106c567050c1921835ba2b116d7507c30 Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Mon, 19 Feb 2024 20:07:17 +0000 Subject: [PATCH 19/43] Update scalafmt-core to 3.8.0 --- .scalafmt.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.scalafmt.conf b/.scalafmt.conf index d69db93533..75fe45e540 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = "3.7.17" +version = "3.8.0" style = default From dfd21001d332b64ae50fa5316e6daefbba6309ce Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Tue, 20 Feb 2024 18:32:13 -0500 Subject: [PATCH 20/43] Remove walkEager optimization and rename walkJustInTime to walkWithAttributes --- .../scala/fs2/io/file/FilesPlatform.scala | 52 +------------------ .../fs2/io/file/AsyncFilesPlatform.scala | 41 --------------- .../fs2/io/file/AsyncFilesPlatform.scala | 38 -------------- 3 files changed, 2 insertions(+), 129 deletions(-) delete mode 100644 io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala delete mode 100644 io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala diff --git a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala index 7b3e6c2f82..2b0af08b81 100644 --- a/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/jvm-native/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -92,8 +92,7 @@ private[file] trait FilesCompanionPlatform { private case class NioFileKey(value: AnyRef) extends FileKey private final class AsyncFiles[F[_]](protected implicit val F: Async[F]) - extends Files.UnsealedFiles[F] - with AsyncFilesPlatform[F] { + extends Files.UnsealedFiles[F] { def copy(source: Path, target: Path, flags: CopyFlags): F[Unit] = Sync[F].blocking { @@ -391,53 +390,6 @@ private[file] trait FilesCompanionPlatform { .resource(Resource.fromAutoCloseable(javaCollection)) .flatMap(ds => Stream.fromBlockingIterator[F](collectionIterator(ds), pathStreamChunkSize)) - protected def walkEager(start: Path, options: WalkOptions): Stream[F, PathInfo] = { - val doWalk = Sync[F].interruptible { - val bldr = Vector.newBuilder[PathInfo] - JFiles.walkFileTree( - start.toNioPath, - if (options.followLinks) Set(FileVisitOption.FOLLOW_LINKS).asJava else Set.empty.asJava, - options.maxDepth, - new SimpleFileVisitor[JPath] { - private def enqueue(path: JPath, attrs: JBasicFileAttributes): FileVisitResult = { - bldr += PathInfo(Path.fromNioPath(path), new DelegatingBasicFileAttributes(attrs)) - FileVisitResult.CONTINUE - } - - override def visitFile(file: JPath, attrs: JBasicFileAttributes): FileVisitResult = - if (Thread.interrupted()) FileVisitResult.TERMINATE else enqueue(file, attrs) - - override def visitFileFailed(file: JPath, t: IOException): FileVisitResult = - t match { - case _: FileSystemLoopException => - if (options.allowCycles) - enqueue( - file, - JFiles.readAttributes( - file, - classOf[JBasicFileAttributes], - LinkOption.NOFOLLOW_LINKS - ) - ) - else throw t - case _ => FileVisitResult.CONTINUE - } - - override def preVisitDirectory( - dir: JPath, - attrs: JBasicFileAttributes - ): FileVisitResult = - if (Thread.interrupted()) FileVisitResult.TERMINATE else enqueue(dir, attrs) - - override def postVisitDirectory(dir: JPath, t: IOException): FileVisitResult = - if (Thread.interrupted()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE - } - ) - Chunk.from(bldr.result()) - } - Stream.eval(doWalk).flatMap(Stream.chunk) - } - private case class WalkEntry( path: Path, attr: JBasicFileAttributes, @@ -445,7 +397,7 @@ private[file] trait FilesCompanionPlatform { ancestry: List[Either[Path, NioFileKey]] ) - protected def walkJustInTime( + override def walkWithAttributes( start: Path, options: WalkOptions ): Stream[F, PathInfo] = { diff --git a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala deleted file mode 100644 index 46b1be40fb..0000000000 --- a/io/jvm/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2013 Functional Streams for Scala - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package fs2 -package io -package file - -private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => - - override def walkWithAttributes( - start: Path, - options: WalkOptions - ): Stream[F, PathInfo] = - if (options.chunkSize == Int.MaxValue) walkEager(start, options) - else walkJustInTime(start, options) - - protected def walkEager(start: Path, options: WalkOptions): Stream[F, PathInfo] - - protected def walkJustInTime( - start: Path, - options: WalkOptions - ): Stream[F, PathInfo] -} diff --git a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala b/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala deleted file mode 100644 index 87796c2134..0000000000 --- a/io/native/src/main/scala/fs2/io/file/AsyncFilesPlatform.scala +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2013 Functional Streams for Scala - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package fs2 -package io -package file - -private[file] trait AsyncFilesPlatform[F[_]] { self: Files.UnsealedFiles[F] => - override def walkWithAttributes( - start: Path, - options: WalkOptions - ): Stream[F, PathInfo] = - // Disable eager walks until https://github.com/scala-native/scala-native/issues/3744 - walkJustInTime(start, options) - - protected def walkJustInTime( - start: Path, - options: WalkOptions - ): Stream[F, PathInfo] -} From 79fcc7217e154ed9e68cddfaeb3cf7edfd3196e3 Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Fri, 23 Feb 2024 08:12:44 +0000 Subject: [PATCH 21/43] Update sbt to 1.9.9 --- project/build.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/build.properties b/project/build.properties index abbbce5da4..04267b14af 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.9.8 +sbt.version=1.9.9 From b763c99757336bc42b37c899914ba9cc86be7b8f Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Sun, 25 Feb 2024 09:18:02 -0500 Subject: [PATCH 22/43] Move default implementation of walkWithAttributes to JS source --- .../scala/fs2/io/file/FilesPlatform.scala | 52 ++++++++++++++++++- .../src/main/scala/fs2/io/file/Files.scala | 49 ----------------- 2 files changed, 50 insertions(+), 51 deletions(-) diff --git a/io/js/src/main/scala/fs2/io/file/FilesPlatform.scala b/io/js/src/main/scala/fs2/io/file/FilesPlatform.scala index f895046adb..6fee6275c1 100644 --- a/io/js/src/main/scala/fs2/io/file/FilesPlatform.scala +++ b/io/js/src/main/scala/fs2/io/file/FilesPlatform.scala @@ -23,8 +23,8 @@ package fs2 package io package file -import cats.effect.kernel.Async -import cats.effect.kernel.Resource +import cats.Traverse +import cats.effect.kernel.{Async, Resource} import cats.syntax.all._ import fs2.io.file.Files.UnsealedFiles import fs2.io.internal.facade @@ -369,6 +369,54 @@ private[fs2] trait FilesCompanionPlatform { override def size(path: Path): F[Long] = stat(path).map(_.size.toString.toLong) + override def walkWithAttributes(start: Path, options: WalkOptions): Stream[F, PathInfo] = { + + def go( + start: Path, + maxDepth: Int, + ancestry: List[Either[Path, FileKey]] + ): Stream[F, PathInfo] = + Stream.eval(getBasicFileAttributes(start, followLinks = false)).mask.flatMap { attr => + Stream.emit(PathInfo(start, attr)) ++ { + if (maxDepth == 0) Stream.empty + else if (attr.isDirectory) + list(start).mask.flatMap { path => + go(path, maxDepth - 1, attr.fileKey.toRight(start) :: ancestry) + } + else if (attr.isSymbolicLink && options.followLinks) + Stream.eval(getBasicFileAttributes(start, followLinks = true)).mask.flatMap { attr => + val fileKey = attr.fileKey + val isCycle = Traverse[List].existsM(ancestry) { + case Right(ancestorKey) => F.pure(fileKey.contains(ancestorKey)) + case Left(ancestorPath) => isSameFile(start, ancestorPath) + } + + Stream.eval(isCycle).flatMap { isCycle => + if (!isCycle) + list(start).mask.flatMap { path => + go(path, maxDepth - 1, attr.fileKey.toRight(start) :: ancestry) + } + else if (options.allowCycles) + Stream.empty + else + Stream.raiseError(new FileSystemLoopException(start.toString)) + } + + } + else + Stream.empty + } + } + + go( + start, + options.maxDepth, + Nil + ) + .chunkN(options.chunkSize) + .flatMap(Stream.chunk) + } + override def writeAll(path: Path, _flags: Flags): Pipe[F, Byte, Nothing] = in => in.through { diff --git a/io/shared/src/main/scala/fs2/io/file/Files.scala b/io/shared/src/main/scala/fs2/io/file/Files.scala index b4f4c0fc9b..f8aaca72c9 100644 --- a/io/shared/src/main/scala/fs2/io/file/Files.scala +++ b/io/shared/src/main/scala/fs2/io/file/Files.scala @@ -31,7 +31,6 @@ import cats.effect.std.Hotswap import cats.syntax.all._ import scala.concurrent.duration._ -import cats.Traverse /** Provides operations related to working with files in the effect `F`. * @@ -525,54 +524,6 @@ object Files extends FilesCompanionPlatform with FilesLowPriority { case _: NoSuchFileException => () }) - def walkWithAttributes(start: Path, options: WalkOptions): Stream[F, PathInfo] = { - - def go( - start: Path, - maxDepth: Int, - ancestry: List[Either[Path, FileKey]] - ): Stream[F, PathInfo] = - Stream.eval(getBasicFileAttributes(start, followLinks = false)).mask.flatMap { attr => - Stream.emit(PathInfo(start, attr)) ++ { - if (maxDepth == 0) Stream.empty - else if (attr.isDirectory) - list(start).mask.flatMap { path => - go(path, maxDepth - 1, attr.fileKey.toRight(start) :: ancestry) - } - else if (attr.isSymbolicLink && options.followLinks) - Stream.eval(getBasicFileAttributes(start, followLinks = true)).mask.flatMap { attr => - val fileKey = attr.fileKey - val isCycle = Traverse[List].existsM(ancestry) { - case Right(ancestorKey) => F.pure(fileKey.contains(ancestorKey)) - case Left(ancestorPath) => isSameFile(start, ancestorPath) - } - - Stream.eval(isCycle).flatMap { isCycle => - if (!isCycle) - list(start).mask.flatMap { path => - go(path, maxDepth - 1, attr.fileKey.toRight(start) :: ancestry) - } - else if (options.allowCycles) - Stream.empty - else - Stream.raiseError(new FileSystemLoopException(start.toString)) - } - - } - else - Stream.empty - } - } - - go( - start, - options.maxDepth, - Nil - ) - .chunkN(options.chunkSize) - .flatMap(Stream.chunk) - } - def writeAll( path: Path, flags: Flags From 65bd3314ae050d0491c3ea996e38271a19e411d4 Mon Sep 17 00:00:00 2001 From: David Francoeur Date: Sun, 25 Feb 2024 23:47:16 -0500 Subject: [PATCH 23/43] add/remove on PosixPermissions --- .../main/scala/fs2/io/file/Permissions.scala | 6 +++++ .../fs2/io/file/PosixPermissionsSuite.scala | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/io/shared/src/main/scala/fs2/io/file/Permissions.scala b/io/shared/src/main/scala/fs2/io/file/Permissions.scala index f8f7772601..abff52ce75 100644 --- a/io/shared/src/main/scala/fs2/io/file/Permissions.scala +++ b/io/shared/src/main/scala/fs2/io/file/Permissions.scala @@ -55,6 +55,12 @@ import PosixPermission._ */ final class PosixPermissions private (val value: Int) extends Permissions { + def add(p: PosixPermission): PosixPermissions = + new PosixPermissions(value | p.value) + + def remove(p: PosixPermission): PosixPermissions = + new PosixPermissions(value ^ p.value) + override def equals(that: Any): Boolean = that match { case other: PosixPermissions => value == other.value case _ => false diff --git a/io/shared/src/test/scala/fs2/io/file/PosixPermissionsSuite.scala b/io/shared/src/test/scala/fs2/io/file/PosixPermissionsSuite.scala index 95692655a5..a91829ce20 100644 --- a/io/shared/src/test/scala/fs2/io/file/PosixPermissionsSuite.scala +++ b/io/shared/src/test/scala/fs2/io/file/PosixPermissionsSuite.scala @@ -59,4 +59,28 @@ class PosixPermissionsSuite extends Fs2IoSuite { assertEquals(PosixPermissions.fromString("rwx"), None) assertEquals(PosixPermissions.fromString("rwxrwxrw?"), None) } + + test("add/remove all") { + case class TC(p: PosixPermission, adding: String, removing: String) + val all = Seq( + TC(PosixPermission.OwnerRead, "400", "377"), + TC(PosixPermission.OwnerWrite, "600", "177"), + TC(PosixPermission.OwnerExecute, "700", "077"), + TC(PosixPermission.GroupRead, "740", "037"), + TC(PosixPermission.GroupWrite, "760", "017"), + TC(PosixPermission.GroupExecute, "770", "007"), + TC(PosixPermission.OthersRead, "774", "003"), + TC(PosixPermission.OthersWrite, "776", "001"), + TC(PosixPermission.OthersExecute, "777", "000") + ) + var goingUp = PosixPermissions.fromInt(0).get + var goingDown = PosixPermissions.fromInt(511).get + all.foreach { case TC(p, adding, removing) => + goingUp = goingUp.add(p) + assertEquals(goingUp, PosixPermissions.fromOctal(adding).get) + + goingDown = goingDown.remove(p) + assertEquals(goingDown, PosixPermissions.fromOctal(removing).get) + } + } } From 4f030e5c6dc2644d5c02ddb0cf3563f88b1e5cba Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 08:40:24 +0000 Subject: [PATCH 24/43] Update sbt-typelevel, sbt-typelevel-site to 0.6.7 --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 65c7352d68..2fc78fc253 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,4 +1,4 @@ -val sbtTypelevelVersion = "0.6.6" +val sbtTypelevelVersion = "0.6.7" addSbtPlugin("org.typelevel" % "sbt-typelevel" % sbtTypelevelVersion) addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % sbtTypelevelVersion) addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.15.0") From 771afaf4256a59534e336973a44da73042d00a13 Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Tue, 27 Feb 2024 00:30:39 +0000 Subject: [PATCH 25/43] Update scala-library to 2.12.19 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 13fe89d646..f2de12966e 100644 --- a/build.sbt +++ b/build.sbt @@ -11,7 +11,7 @@ ThisBuild / startYear := Some(2013) val Scala213 = "2.13.12" ThisBuild / scalaVersion := Scala213 -ThisBuild / crossScalaVersions := Seq("2.12.18", Scala213, "3.3.1") +ThisBuild / crossScalaVersions := Seq("2.12.19", Scala213, "3.3.1") ThisBuild / tlVersionIntroduced := Map("3" -> "3.0.3") ThisBuild / githubWorkflowOSes := Seq("ubuntu-latest") From 22d3a45672e598e766df371accc0b61293c3a017 Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Tue, 27 Feb 2024 07:48:49 +0000 Subject: [PATCH 26/43] flake.lock: Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flake lock file updates: • Updated input 'typelevel-nix': 'github:typelevel/typelevel-nix/85318b2ace46ee59560d5a5ce1a56ee08b46b15e' (2024-02-12) → 'github:typelevel/typelevel-nix/035bec68f65a213bf6080c7aeb18ba88b9fe9b5f' (2024-02-26) • Updated input 'typelevel-nix/nixpkgs': 'github:nixos/nixpkgs/f3a93440fbfff8a74350f4791332a19282cc6dc8' (2024-02-11) → 'github:nixos/nixpkgs/2a34566b67bef34c551f204063faeecc444ae9da' (2024-02-25) --- flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index 105ffb7458..6c68848764 100644 --- a/flake.lock +++ b/flake.lock @@ -73,11 +73,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1707619277, - "narHash": "sha256-vKnYD5GMQbNQyyQm4wRlqi+5n0/F1hnvqSQgaBy4BqY=", + "lastModified": 1708847675, + "narHash": "sha256-RUZ7KEs/a4EzRELYDGnRB6i7M1Izii3JD/LyzH0c6Tg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "f3a93440fbfff8a74350f4791332a19282cc6dc8", + "rev": "2a34566b67bef34c551f204063faeecc444ae9da", "type": "github" }, "original": { @@ -137,11 +137,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1707745269, - "narHash": "sha256-0mwnDOYtq6TNfKKOhMa66o4gDOZXTjY5kho43tTeQ24=", + "lastModified": 1708920671, + "narHash": "sha256-mzSNb+bwOKtbgf2d/ypxR+mHsnzBPOXzssrckpr7RU0=", "owner": "typelevel", "repo": "typelevel-nix", - "rev": "85318b2ace46ee59560d5a5ce1a56ee08b46b15e", + "rev": "035bec68f65a213bf6080c7aeb18ba88b9fe9b5f", "type": "github" }, "original": { From 1c66d408ffbea04fb5ebbdf3bc2cae4d2c73f729 Mon Sep 17 00:00:00 2001 From: mpilquist Date: Wed, 28 Feb 2024 09:54:35 -0500 Subject: [PATCH 27/43] Add initial implementation of conflate and conflateWithSeed --- core/shared/src/main/scala/fs2/Stream.scala | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/core/shared/src/main/scala/fs2/Stream.scala b/core/shared/src/main/scala/fs2/Stream.scala index 4ceaff897c..01d9d2b707 100644 --- a/core/shared/src/main/scala/fs2/Stream.scala +++ b/core/shared/src/main/scala/fs2/Stream.scala @@ -568,6 +568,56 @@ final class Stream[+F[_], +O] private[fs2] (private[fs2] val underlying: Pull[F, Stream.eval(fstream) } + def conflate[F2[x] >: F[x], O2 >: O](implicit + F: Concurrent[F2], + O: Semigroup[O2] + ): Stream[F2, O2] = + conflateWithSeed[F2, O2](identity)((s, o) => O.combine(s, o)) + + def conflateWithSeed[F2[x] >: F[x], S]( + seed: O => S + )(aggregate: (S, O) => S)(implicit F: Concurrent[F2]): Stream[F2, S] = { + + sealed trait State + case object Closed extends State + case object Quiet extends State + case class Accumulating(value: S) extends State + case class Awaiting(waiter: Deferred[F2, S]) extends State + + Stream.eval(F.ref(Quiet: State)).flatMap { ref => + val producer = foreach { o => + ref.flatModify { + case Closed => + (Closed, F.unit) + case Quiet => + (Accumulating(seed(o)), F.unit) + case Accumulating(acc) => + (Accumulating(aggregate(acc, o)), F.unit) + case Awaiting(waiter) => + (Quiet, waiter.complete(seed(o)).void) + } + } + + def consumerLoop: Pull[F2, S, Unit] = + Pull.eval { + F.deferred[S].flatMap { waiter => + ref.modify { + case Closed => + (Closed, Pull.done) + case Quiet => + (Awaiting(waiter), Pull.eval(waiter.get).flatMap(Pull.output1) >> consumerLoop) + case Accumulating(s) => + (Quiet, Pull.output1(s) >> consumerLoop) + case Awaiting(_) => + sys.error("Impossible") + } + } + }.flatten + + consumerLoop.stream.concurrently(producer) + } + } + /** Prepends a chunk onto the front of this stream. * * @example {{{ From 3604eedb6331a775b19caf21fb03c756ef105da6 Mon Sep 17 00:00:00 2001 From: mpilquist Date: Wed, 28 Feb 2024 21:11:56 -0500 Subject: [PATCH 28/43] Add test for conflate --- .../test/scala/fs2/StreamConflateSuite.scala | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 core/shared/src/test/scala/fs2/StreamConflateSuite.scala diff --git a/core/shared/src/test/scala/fs2/StreamConflateSuite.scala b/core/shared/src/test/scala/fs2/StreamConflateSuite.scala new file mode 100644 index 0000000000..d733ddfe6e --- /dev/null +++ b/core/shared/src/test/scala/fs2/StreamConflateSuite.scala @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2013 Functional Streams for Scala + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package fs2 + +import cats.effect.IO +import cats.effect.testkit.TestControl + +import scala.concurrent.duration._ + +class StreamConflateSuite extends Fs2Suite { + + test("conflate") { + TestControl.executeEmbed( + Stream + .iterate(0)(_ + 1) + .covary[IO] + .metered(10.millis) + .map(List(_)) + .conflate + .metered(101.millis) + .take(5) + .compile + .toList + .assertEquals( + List(0) :: (1 until 10).toList :: 10.until(40).toList.grouped(10).toList + ) + ) + } +} From 5a522c508984d355575c6d775969c11f0ae6c21b Mon Sep 17 00:00:00 2001 From: mpilquist Date: Thu, 29 Feb 2024 08:38:05 -0500 Subject: [PATCH 29/43] Reimplement conflate operations --- core/shared/src/main/scala/fs2/Stream.scala | 62 +++++-------------- .../test/scala/fs2/StreamConflateSuite.scala | 5 +- 2 files changed, 17 insertions(+), 50 deletions(-) diff --git a/core/shared/src/main/scala/fs2/Stream.scala b/core/shared/src/main/scala/fs2/Stream.scala index 01d9d2b707..06e6e41fe1 100644 --- a/core/shared/src/main/scala/fs2/Stream.scala +++ b/core/shared/src/main/scala/fs2/Stream.scala @@ -568,55 +568,24 @@ final class Stream[+F[_], +O] private[fs2] (private[fs2] val underlying: Pull[F, Stream.eval(fstream) } - def conflate[F2[x] >: F[x], O2 >: O](implicit - F: Concurrent[F2], - O: Semigroup[O2] - ): Stream[F2, O2] = - conflateWithSeed[F2, O2](identity)((s, o) => O.combine(s, o)) + def conflateChunks[F2[x] >: F[x]: Concurrent](chunkLimit: Int): Stream[F2, Chunk[O]] = + Stream.eval(Channel.bounded[F2, Chunk[O]](chunkLimit)).flatMap { chan => + val producer = chunks.through(chan.sendAll) + val consumer = chan.stream.underlying.unconsFlatMap(chunks => Pull.output1(chunks.iterator.reduce(_ ++ _))).stream + consumer.concurrently(producer) + } - def conflateWithSeed[F2[x] >: F[x], S]( - seed: O => S - )(aggregate: (S, O) => S)(implicit F: Concurrent[F2]): Stream[F2, S] = { + def conflate[F2[x] >: F[x]: Concurrent, O2](chunkLimit: Int, zero: O2)(f: (O2, O) => O2): Stream[F2, O2] = + conflateChunks[F2](chunkLimit).map(_.foldLeft(zero)(f)) - sealed trait State - case object Closed extends State - case object Quiet extends State - case class Accumulating(value: S) extends State - case class Awaiting(waiter: Deferred[F2, S]) extends State - - Stream.eval(F.ref(Quiet: State)).flatMap { ref => - val producer = foreach { o => - ref.flatModify { - case Closed => - (Closed, F.unit) - case Quiet => - (Accumulating(seed(o)), F.unit) - case Accumulating(acc) => - (Accumulating(aggregate(acc, o)), F.unit) - case Awaiting(waiter) => - (Quiet, waiter.complete(seed(o)).void) - } - } + def conflate1[F2[x] >: F[x]: Concurrent, O2 >: O](chunkLimit: Int)(f: (O2, O2) => O2): Stream[F2, O2] = + conflateChunks[F2](chunkLimit).map(c => c.drop(1).foldLeft(c(0): O2)((x, y) => f(x, y))) - def consumerLoop: Pull[F2, S, Unit] = - Pull.eval { - F.deferred[S].flatMap { waiter => - ref.modify { - case Closed => - (Closed, Pull.done) - case Quiet => - (Awaiting(waiter), Pull.eval(waiter.get).flatMap(Pull.output1) >> consumerLoop) - case Accumulating(s) => - (Quiet, Pull.output1(s) >> consumerLoop) - case Awaiting(_) => - sys.error("Impossible") - } - } - }.flatten + def conflateSemigroup[F2[x] >: F[x]: Concurrent, O2 >: O: Semigroup](chunkLimit: Int): Stream[F2, O2] = + conflate1[F2, O2](chunkLimit)(Semigroup[O2].combine) - consumerLoop.stream.concurrently(producer) - } - } + def conflateMap[F2[x] >: F[x]: Concurrent, O2: Semigroup](chunkLimit: Int)(f: O => O2): Stream[F2, O2] = + map(f).conflateSemigroup[F2, O2](chunkLimit) /** Prepends a chunk onto the front of this stream. * @@ -2448,10 +2417,9 @@ final class Stream[+F[_], +O] private[fs2] (private[fs2] val underlying: Pull[F, Stream.eval(Channel.bounded[F2, Chunk[O]](n)).flatMap { chan => chan.stream.unchunks.concurrently { chunks.through(chan.sendAll) - } } - + /** Prints each element of this stream to standard out, converting each element to a `String` via `Show`. */ def printlns[F2[x] >: F[x], O2 >: O](implicit F: Console[F2], diff --git a/core/shared/src/test/scala/fs2/StreamConflateSuite.scala b/core/shared/src/test/scala/fs2/StreamConflateSuite.scala index d733ddfe6e..c50b7c4451 100644 --- a/core/shared/src/test/scala/fs2/StreamConflateSuite.scala +++ b/core/shared/src/test/scala/fs2/StreamConflateSuite.scala @@ -28,14 +28,13 @@ import scala.concurrent.duration._ class StreamConflateSuite extends Fs2Suite { - test("conflate") { + test("conflateMap") { TestControl.executeEmbed( Stream .iterate(0)(_ + 1) .covary[IO] .metered(10.millis) - .map(List(_)) - .conflate + .conflateMap(100)(List(_)) .metered(101.millis) .take(5) .compile From 33320faa9ffee60268b6ba11164d3eb7a766b01f Mon Sep 17 00:00:00 2001 From: mpilquist Date: Thu, 29 Feb 2024 08:48:38 -0500 Subject: [PATCH 30/43] Further simplify conflateChunks --- core/shared/src/main/scala/fs2/Stream.scala | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/core/shared/src/main/scala/fs2/Stream.scala b/core/shared/src/main/scala/fs2/Stream.scala index 06e6e41fe1..d439e200bf 100644 --- a/core/shared/src/main/scala/fs2/Stream.scala +++ b/core/shared/src/main/scala/fs2/Stream.scala @@ -571,20 +571,28 @@ final class Stream[+F[_], +O] private[fs2] (private[fs2] val underlying: Pull[F, def conflateChunks[F2[x] >: F[x]: Concurrent](chunkLimit: Int): Stream[F2, Chunk[O]] = Stream.eval(Channel.bounded[F2, Chunk[O]](chunkLimit)).flatMap { chan => val producer = chunks.through(chan.sendAll) - val consumer = chan.stream.underlying.unconsFlatMap(chunks => Pull.output1(chunks.iterator.reduce(_ ++ _))).stream + val consumer = chan.stream.chunks.map(_.combineAll) consumer.concurrently(producer) } - def conflate[F2[x] >: F[x]: Concurrent, O2](chunkLimit: Int, zero: O2)(f: (O2, O) => O2): Stream[F2, O2] = + def conflate[F2[x] >: F[x]: Concurrent, O2](chunkLimit: Int, zero: O2)( + f: (O2, O) => O2 + ): Stream[F2, O2] = conflateChunks[F2](chunkLimit).map(_.foldLeft(zero)(f)) - def conflate1[F2[x] >: F[x]: Concurrent, O2 >: O](chunkLimit: Int)(f: (O2, O2) => O2): Stream[F2, O2] = + def conflate1[F2[x] >: F[x]: Concurrent, O2 >: O](chunkLimit: Int)( + f: (O2, O2) => O2 + ): Stream[F2, O2] = conflateChunks[F2](chunkLimit).map(c => c.drop(1).foldLeft(c(0): O2)((x, y) => f(x, y))) - def conflateSemigroup[F2[x] >: F[x]: Concurrent, O2 >: O: Semigroup](chunkLimit: Int): Stream[F2, O2] = + def conflateSemigroup[F2[x] >: F[x]: Concurrent, O2 >: O: Semigroup]( + chunkLimit: Int + ): Stream[F2, O2] = conflate1[F2, O2](chunkLimit)(Semigroup[O2].combine) - def conflateMap[F2[x] >: F[x]: Concurrent, O2: Semigroup](chunkLimit: Int)(f: O => O2): Stream[F2, O2] = + def conflateMap[F2[x] >: F[x]: Concurrent, O2: Semigroup](chunkLimit: Int)( + f: O => O2 + ): Stream[F2, O2] = map(f).conflateSemigroup[F2, O2](chunkLimit) /** Prepends a chunk onto the front of this stream. @@ -2419,7 +2427,7 @@ final class Stream[+F[_], +O] private[fs2] (private[fs2] val underlying: Pull[F, chunks.through(chan.sendAll) } } - + /** Prints each element of this stream to standard out, converting each element to a `String` via `Show`. */ def printlns[F2[x] >: F[x], O2 >: O](implicit F: Console[F2], From 155e650dfc741431a4ca1bd5d783c1c1d201d4ad Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:16:31 +0000 Subject: [PATCH 31/43] Update scala3-library, ... to 3.3.3 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 13fe89d646..c503a43401 100644 --- a/build.sbt +++ b/build.sbt @@ -11,7 +11,7 @@ ThisBuild / startYear := Some(2013) val Scala213 = "2.13.12" ThisBuild / scalaVersion := Scala213 -ThisBuild / crossScalaVersions := Seq("2.12.18", Scala213, "3.3.1") +ThisBuild / crossScalaVersions := Seq("2.12.18", Scala213, "3.3.3") ThisBuild / tlVersionIntroduced := Map("3" -> "3.0.3") ThisBuild / githubWorkflowOSes := Seq("ubuntu-latest") From 87f32291105a122d95463ee977aca16b9769ec95 Mon Sep 17 00:00:00 2001 From: mpilquist Date: Thu, 29 Feb 2024 16:17:13 -0500 Subject: [PATCH 32/43] Docs for conflate functions --- core/shared/src/main/scala/fs2/Stream.scala | 25 +++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/core/shared/src/main/scala/fs2/Stream.scala b/core/shared/src/main/scala/fs2/Stream.scala index d439e200bf..7ce0341b36 100644 --- a/core/shared/src/main/scala/fs2/Stream.scala +++ b/core/shared/src/main/scala/fs2/Stream.scala @@ -568,6 +568,12 @@ final class Stream[+F[_], +O] private[fs2] (private[fs2] val underlying: Pull[F, Stream.eval(fstream) } + /** Pulls up to the specified number of chunks from the source stream while concurrently allowing + * downstream to process emitted chunks. Unlike `prefetchN`, all accumulated chunks are emitted + * as a single chunk upon downstream pulling. + * + * The `chunkLimit` parameter controls backpressure on the source stream. + */ def conflateChunks[F2[x] >: F[x]: Concurrent](chunkLimit: Int): Stream[F2, Chunk[O]] = Stream.eval(Channel.bounded[F2, Chunk[O]](chunkLimit)).flatMap { chan => val producer = chunks.through(chan.sendAll) @@ -575,25 +581,36 @@ final class Stream[+F[_], +O] private[fs2] (private[fs2] val underlying: Pull[F, consumer.concurrently(producer) } + /** Like `conflateChunks` but uses the supplied `zero` and `f` values to combine the elements of + * each output chunk in to a single value. + */ def conflate[F2[x] >: F[x]: Concurrent, O2](chunkLimit: Int, zero: O2)( f: (O2, O) => O2 ): Stream[F2, O2] = conflateChunks[F2](chunkLimit).map(_.foldLeft(zero)(f)) + /** Like `conflate` but combines elements of the output chunk with the supplied function. + */ def conflate1[F2[x] >: F[x]: Concurrent, O2 >: O](chunkLimit: Int)( f: (O2, O2) => O2 ): Stream[F2, O2] = conflateChunks[F2](chunkLimit).map(c => c.drop(1).foldLeft(c(0): O2)((x, y) => f(x, y))) + /** Like `conflate1` but combines elements using the semigroup of the output type. + */ def conflateSemigroup[F2[x] >: F[x]: Concurrent, O2 >: O: Semigroup]( chunkLimit: Int ): Stream[F2, O2] = conflate1[F2, O2](chunkLimit)(Semigroup[O2].combine) - def conflateMap[F2[x] >: F[x]: Concurrent, O2: Semigroup](chunkLimit: Int)( - f: O => O2 - ): Stream[F2, O2] = - map(f).conflateSemigroup[F2, O2](chunkLimit) + /** Conflates elements and then maps the supplied function over the output chunk and combines the results using a semigroup. + */ + def conflateMap[F2[x] >: F[x]: Concurrent, O2: Semigroup]( + chunkLimit: Int + )(f: O => O2): Stream[F2, O2] = + conflateChunks[F2](chunkLimit).map(c => + c.drop(1).foldLeft(f(c(0)))((x, y) => Semigroup[O2].combine(x, f(y))) + ) /** Prepends a chunk onto the front of this stream. * From b939a1ff54186e6d06c0911e2b492cf45b1fe4e4 Mon Sep 17 00:00:00 2001 From: Michael Pilquist Date: Fri, 1 Mar 2024 22:57:26 -0500 Subject: [PATCH 33/43] Slight refactor of chunk reduction --- core/shared/src/main/scala/fs2/Stream.scala | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/core/shared/src/main/scala/fs2/Stream.scala b/core/shared/src/main/scala/fs2/Stream.scala index 7ce0341b36..78a3c5f8d5 100644 --- a/core/shared/src/main/scala/fs2/Stream.scala +++ b/core/shared/src/main/scala/fs2/Stream.scala @@ -594,7 +594,7 @@ final class Stream[+F[_], +O] private[fs2] (private[fs2] val underlying: Pull[F, def conflate1[F2[x] >: F[x]: Concurrent, O2 >: O](chunkLimit: Int)( f: (O2, O2) => O2 ): Stream[F2, O2] = - conflateChunks[F2](chunkLimit).map(c => c.drop(1).foldLeft(c(0): O2)((x, y) => f(x, y))) + conflateChunks[F2](chunkLimit).map(_.iterator.reduce(f)) /** Like `conflate1` but combines elements using the semigroup of the output type. */ @@ -608,9 +608,7 @@ final class Stream[+F[_], +O] private[fs2] (private[fs2] val underlying: Pull[F, def conflateMap[F2[x] >: F[x]: Concurrent, O2: Semigroup]( chunkLimit: Int )(f: O => O2): Stream[F2, O2] = - conflateChunks[F2](chunkLimit).map(c => - c.drop(1).foldLeft(f(c(0)))((x, y) => Semigroup[O2].combine(x, f(y))) - ) + conflateChunks[F2](chunkLimit).map(_.iterator.map(f).reduce(Semigroup[O2].combine)) /** Prepends a chunk onto the front of this stream. * From 5267230e63fd696d2bf5c908c88fdd07c722dd22 Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 07:48:53 +0000 Subject: [PATCH 34/43] flake.lock: Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flake lock file updates: • Updated input 'typelevel-nix': 'github:typelevel/typelevel-nix/035bec68f65a213bf6080c7aeb18ba88b9fe9b5f' (2024-02-26) → 'github:typelevel/typelevel-nix/52169f0a21ffe51a16598423290ebf7d0d6cc2b1' (2024-03-04) • Updated input 'typelevel-nix/devshell': 'github:numtide/devshell/83cb93d6d063ad290beee669f4badf9914cc16ec' (2024-01-15) → 'github:numtide/devshell/5ddecd67edbd568ebe0a55905273e56cc82aabe3' (2024-02-26) • Updated input 'typelevel-nix/flake-utils': 'github:numtide/flake-utils/1ef2e671c3b0c19053962c07dbda38332dcebf26' (2024-01-15) → 'github:numtide/flake-utils/d465f4819400de7c8d874d50b982301f28a84605' (2024-02-28) • Updated input 'typelevel-nix/nixpkgs': 'github:nixos/nixpkgs/2a34566b67bef34c551f204063faeecc444ae9da' (2024-02-25) → 'github:nixos/nixpkgs/fa9a51752f1b5de583ad5213eb621be071806663' (2024-03-02) --- flake.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/flake.lock b/flake.lock index 6c68848764..0769b3f9e3 100644 --- a/flake.lock +++ b/flake.lock @@ -6,11 +6,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1705332421, - "narHash": "sha256-USpGLPme1IuqG78JNqSaRabilwkCyHmVWY0M9vYyqEA=", + "lastModified": 1708939976, + "narHash": "sha256-O5+nFozxz2Vubpdl1YZtPrilcIXPcRAjqNdNE8oCRoA=", "owner": "numtide", "repo": "devshell", - "rev": "83cb93d6d063ad290beee669f4badf9914cc16ec", + "rev": "5ddecd67edbd568ebe0a55905273e56cc82aabe3", "type": "github" }, "original": { @@ -42,11 +42,11 @@ "systems": "systems_2" }, "locked": { - "lastModified": 1705309234, - "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "lastModified": 1709126324, + "narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=", "owner": "numtide", "repo": "flake-utils", - "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "rev": "d465f4819400de7c8d874d50b982301f28a84605", "type": "github" }, "original": { @@ -73,11 +73,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1708847675, - "narHash": "sha256-RUZ7KEs/a4EzRELYDGnRB6i7M1Izii3JD/LyzH0c6Tg=", + "lastModified": 1709386671, + "narHash": "sha256-VPqfBnIJ+cfa78pd4Y5Cr6sOWVW8GYHRVucxJGmRf8Q=", "owner": "nixos", "repo": "nixpkgs", - "rev": "2a34566b67bef34c551f204063faeecc444ae9da", + "rev": "fa9a51752f1b5de583ad5213eb621be071806663", "type": "github" }, "original": { @@ -137,11 +137,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1708920671, - "narHash": "sha256-mzSNb+bwOKtbgf2d/ypxR+mHsnzBPOXzssrckpr7RU0=", + "lastModified": 1709579932, + "narHash": "sha256-6FXB4+iqwPAoYr1nbUpP8wtV09cPpnZyOIq5z58IhOs=", "owner": "typelevel", "repo": "typelevel-nix", - "rev": "035bec68f65a213bf6080c7aeb18ba88b9fe9b5f", + "rev": "52169f0a21ffe51a16598423290ebf7d0d6cc2b1", "type": "github" }, "original": { From 34cf847a4552389dbb1f533adceac4202828aa6e Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 00:18:08 +0000 Subject: [PATCH 35/43] Update cats-effect, cats-effect-laws, ... to 3.5.4 --- build.sbt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.sbt b/build.sbt index 11acfdf28a..389f62701c 100644 --- a/build.sbt +++ b/build.sbt @@ -278,9 +278,9 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) libraryDependencies ++= Seq( "org.scodec" %%% "scodec-bits" % "1.1.38", "org.typelevel" %%% "cats-core" % "2.10.0", - "org.typelevel" %%% "cats-effect" % "3.5.3", - "org.typelevel" %%% "cats-effect-laws" % "3.5.3" % Test, - "org.typelevel" %%% "cats-effect-testkit" % "3.5.3" % Test, + "org.typelevel" %%% "cats-effect" % "3.5.4", + "org.typelevel" %%% "cats-effect-laws" % "3.5.4" % Test, + "org.typelevel" %%% "cats-effect-testkit" % "3.5.4" % Test, "org.typelevel" %%% "cats-laws" % "2.10.0" % Test, "org.typelevel" %%% "discipline-munit" % "2.0.0-M3" % Test, "org.typelevel" %%% "munit-cats-effect" % "2.0.0-M4" % Test, From 2a46b8515e9baed76777e62e9946750b9c28beeb Mon Sep 17 00:00:00 2001 From: mpilquist Date: Fri, 8 Mar 2024 08:53:38 -0500 Subject: [PATCH 36/43] Add retries to UdpSuite --- .../test/scala/fs2/io/net/udp/UdpSuite.scala | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/io/js-jvm/src/test/scala/fs2/io/net/udp/UdpSuite.scala b/io/js-jvm/src/test/scala/fs2/io/net/udp/UdpSuite.scala index 50f8934d6a..ed1af56590 100644 --- a/io/js-jvm/src/test/scala/fs2/io/net/udp/UdpSuite.scala +++ b/io/js-jvm/src/test/scala/fs2/io/net/udp/UdpSuite.scala @@ -29,7 +29,13 @@ import cats.syntax.all._ import com.comcast.ip4s._ +import scala.concurrent.duration._ + class UdpSuite extends Fs2Suite with UdpSuitePlatform { + def sendAndReceive(socket: DatagramSocket[IO], toSend: Datagram): IO[Datagram] = + socket + .write(toSend) >> socket.read.timeoutTo(100.millis, IO.defer(sendAndReceive(socket, toSend))) + group("udp") { test("echo one") { val msg = Chunk.array("Hello, world!".getBytes) @@ -38,15 +44,11 @@ class UdpSuite extends Fs2Suite with UdpSuitePlatform { .flatMap { serverSocket => Stream.eval(serverSocket.localAddress).map(_.port).flatMap { serverPort => val serverAddress = SocketAddress(ip"127.0.0.1", serverPort) - val server = serverSocket.reads - .evalMap(packet => serverSocket.write(packet)) - .drain - val client = Stream.resource(Network[IO].openDatagramSocket()).flatMap { clientSocket => - Stream(Datagram(serverAddress, msg)) - .through(clientSocket.writes) - .drain ++ Stream.eval(clientSocket.read) + val server = serverSocket.reads.foreach(packet => serverSocket.write(packet)) + val client = Stream.resource(Network[IO].openDatagramSocket()).evalMap { clientSocket => + sendAndReceive(clientSocket, Datagram(serverAddress, msg)) } - server.mergeHaltBoth(client) + client.concurrently(server) } } .compile @@ -69,21 +71,17 @@ class UdpSuite extends Fs2Suite with UdpSuitePlatform { .flatMap { serverSocket => Stream.eval(serverSocket.localAddress).map(_.port).flatMap { serverPort => val serverAddress = SocketAddress(ip"127.0.0.1", serverPort) - val server = serverSocket.reads - .evalMap(packet => serverSocket.write(packet)) - .drain + val server = serverSocket.reads.foreach(packet => serverSocket.write(packet)) val client = Stream.resource(Network[IO].openDatagramSocket()).flatMap { clientSocket => Stream .emits(msgs.map(msg => Datagram(serverAddress, msg))) - .flatMap { msg => - Stream.exec(clientSocket.write(msg)) ++ Stream.eval(clientSocket.read) - } + .evalMap(msg => sendAndReceive(clientSocket, msg)) } val clients = Stream .constant(client) .take(numClients.toLong) .parJoin(numParallelClients) - server.mergeHaltBoth(clients) + clients.concurrently(server) } } .compile @@ -110,15 +108,13 @@ class UdpSuite extends Fs2Suite with UdpSuitePlatform { .exec( v4Interfaces.traverse_(interface => serverSocket.join(groupJoin, interface)) ) ++ - serverSocket.reads - .evalMap(packet => serverSocket.write(packet)) - .drain + serverSocket.reads.foreach(packet => serverSocket.write(packet)) val client = Stream.resource(Network[IO].openDatagramSocket()).flatMap { clientSocket => Stream(Datagram(SocketAddress(group.address, serverPort), msg)) .through(clientSocket.writes) .drain ++ Stream.eval(clientSocket.read) } - server.mergeHaltBoth(client) + client.concurrently(server) } } .compile From ce52e21fa1ced51d75edf81eb7f897dce420b454 Mon Sep 17 00:00:00 2001 From: mpilquist Date: Fri, 8 Mar 2024 09:45:26 -0500 Subject: [PATCH 37/43] Bump retry timeout --- io/js-jvm/src/test/scala/fs2/io/net/udp/UdpSuite.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io/js-jvm/src/test/scala/fs2/io/net/udp/UdpSuite.scala b/io/js-jvm/src/test/scala/fs2/io/net/udp/UdpSuite.scala index ed1af56590..19fd4effc2 100644 --- a/io/js-jvm/src/test/scala/fs2/io/net/udp/UdpSuite.scala +++ b/io/js-jvm/src/test/scala/fs2/io/net/udp/UdpSuite.scala @@ -34,7 +34,7 @@ import scala.concurrent.duration._ class UdpSuite extends Fs2Suite with UdpSuitePlatform { def sendAndReceive(socket: DatagramSocket[IO], toSend: Datagram): IO[Datagram] = socket - .write(toSend) >> socket.read.timeoutTo(100.millis, IO.defer(sendAndReceive(socket, toSend))) + .write(toSend) >> socket.read.timeoutTo(1.second, IO.defer(sendAndReceive(socket, toSend))) group("udp") { test("echo one") { From 14203f5da2a4915b11f1a473c1174056902c6a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Miguel=20Mej=C3=ADa=20Su=C3=A1rez?= Date: Fri, 8 Mar 2024 11:00:58 -0500 Subject: [PATCH 38/43] Make Chunk.asSeqPlatform private --- core/shared/src/main/scala-2.12/fs2/ChunkPlatform.scala | 2 +- core/shared/src/main/scala-2.13/fs2/ChunkPlatform.scala | 2 +- core/shared/src/main/scala-3/fs2/ChunkPlatform.scala | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/shared/src/main/scala-2.12/fs2/ChunkPlatform.scala b/core/shared/src/main/scala-2.12/fs2/ChunkPlatform.scala index 041bffd2c0..a0d4461198 100644 --- a/core/shared/src/main/scala-2.12/fs2/ChunkPlatform.scala +++ b/core/shared/src/main/scala-2.12/fs2/ChunkPlatform.scala @@ -31,7 +31,7 @@ import scala.reflect.ClassTag private[fs2] trait ChunkPlatform[+O] { self: Chunk[O] => - def asSeqPlatform: Option[IndexedSeq[O]] = + private[fs2] def asSeqPlatform: Option[IndexedSeq[O]] = None } diff --git a/core/shared/src/main/scala-2.13/fs2/ChunkPlatform.scala b/core/shared/src/main/scala-2.13/fs2/ChunkPlatform.scala index e7f2353c98..44e5ce4e76 100644 --- a/core/shared/src/main/scala-2.13/fs2/ChunkPlatform.scala +++ b/core/shared/src/main/scala-2.13/fs2/ChunkPlatform.scala @@ -26,7 +26,7 @@ import scala.collection.immutable.ArraySeq private[fs2] trait ChunkPlatform[+O] extends Chunk213And3Compat[O] { self: Chunk[O] => - def asSeqPlatform: Option[IndexedSeq[O]] = + private[fs2] def asSeqPlatform: Option[IndexedSeq[O]] = this match { case arraySlice: Chunk.ArraySlice[?] => Some( diff --git a/core/shared/src/main/scala-3/fs2/ChunkPlatform.scala b/core/shared/src/main/scala-3/fs2/ChunkPlatform.scala index 3ab49ed656..1fb816e0e4 100644 --- a/core/shared/src/main/scala-3/fs2/ChunkPlatform.scala +++ b/core/shared/src/main/scala-3/fs2/ChunkPlatform.scala @@ -39,7 +39,7 @@ private[fs2] trait ChunkPlatform[+O] extends Chunk213And3Compat[O] { case _ => new Chunk.IArraySlice(IArray.unsafeFromArray(toArray(ct)), 0, size) } - def asSeqPlatform: Option[IndexedSeq[O]] = + private[fs2] def asSeqPlatform: Option[IndexedSeq[O]] = this match { case arraySlice: Chunk.ArraySlice[_] => Some( From cfad15a2109186a7195b0ecd6b445220fe8ac620 Mon Sep 17 00:00:00 2001 From: "typelevel-steward[bot]" <106827141+typelevel-steward[bot]@users.noreply.github.com> Date: Tue, 12 Mar 2024 07:48:45 +0000 Subject: [PATCH 39/43] flake.lock: Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flake lock file updates: • Updated input 'typelevel-nix': 'github:typelevel/typelevel-nix/52169f0a21ffe51a16598423290ebf7d0d6cc2b1' (2024-03-04) → 'github:typelevel/typelevel-nix/60c3868688cb8f5f7ebc781f6e122c061ae35d4d' (2024-03-11) • Updated input 'typelevel-nix/nixpkgs': 'github:nixos/nixpkgs/fa9a51752f1b5de583ad5213eb621be071806663' (2024-03-02) → 'github:nixos/nixpkgs/d40e866b1f98698d454dad8f592fe7616ff705a4' (2024-03-10) --- flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index 0769b3f9e3..80c450b76b 100644 --- a/flake.lock +++ b/flake.lock @@ -73,11 +73,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1709386671, - "narHash": "sha256-VPqfBnIJ+cfa78pd4Y5Cr6sOWVW8GYHRVucxJGmRf8Q=", + "lastModified": 1710097495, + "narHash": "sha256-B7Ea7q7hU7SE8wOPJ9oXEBjvB89yl2csaLjf5v/7jr8=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fa9a51752f1b5de583ad5213eb621be071806663", + "rev": "d40e866b1f98698d454dad8f592fe7616ff705a4", "type": "github" }, "original": { @@ -137,11 +137,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1709579932, - "narHash": "sha256-6FXB4+iqwPAoYr1nbUpP8wtV09cPpnZyOIq5z58IhOs=", + "lastModified": 1710188850, + "narHash": "sha256-KbNmyxEvcnq5h/wfeL1ZxO9RwoNRjJ0IgYlUZpdSlLo=", "owner": "typelevel", "repo": "typelevel-nix", - "rev": "52169f0a21ffe51a16598423290ebf7d0d6cc2b1", + "rev": "60c3868688cb8f5f7ebc781f6e122c061ae35d4d", "type": "github" }, "original": { From eac93cefc408b82185a5d83cd6acdb633878a768 Mon Sep 17 00:00:00 2001 From: Sven Behrens Date: Sat, 23 Mar 2024 18:52:29 +0100 Subject: [PATCH 40/43] Fix ignored parameters in deprecated Files.walk() --- io/shared/src/main/scala/fs2/io/file/Files.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io/shared/src/main/scala/fs2/io/file/Files.scala b/io/shared/src/main/scala/fs2/io/file/Files.scala index f8aaca72c9..ae618aba1d 100644 --- a/io/shared/src/main/scala/fs2/io/file/Files.scala +++ b/io/shared/src/main/scala/fs2/io/file/Files.scala @@ -391,7 +391,7 @@ sealed trait Files[F[_]] extends FilesPlatform[F] { */ @deprecated("Use walk(start, WalkOptions.Default.withMaxDepth(..).withFollowLinks(..))", "3.10") def walk(start: Path, maxDepth: Int, followLinks: Boolean): Stream[F, Path] = - walk(start, WalkOptions.Default) + walk(start, WalkOptions.Default.withMaxDepth(maxDepth).withFollowLinks(followLinks)) /** Like `walk` but returns a `PathInfo`, which provides both the `Path` and `BasicFileAttributes`. */ def walkWithAttributes(start: Path): Stream[F, PathInfo] = From 3fd3808c68e6126a8340d02722efa0d09ea08eb1 Mon Sep 17 00:00:00 2001 From: Andrew Valencik Date: Mon, 25 Mar 2024 19:28:43 -0400 Subject: [PATCH 41/43] Remove CODE_OF_CONDUCT override, use org default --- CODE_OF_CONDUCT.md | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index ea4a9b6ec2..0000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,14 +0,0 @@ -# Code of Conduct - -We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other such characteristics. - -Everyone is expected to follow the [Scala Code of Conduct](https://www.scala-lang.org/conduct/) when discussing the project on the available communication channels. If you are being harassed, please contact us immediately so that we can support you. - -## Moderation - -Any questions, concerns, or moderation requests please contact a member of the project. - -- Michael Pilquist [Email](mailto:mpilquist@gmail.com) [Twitter](https://twitter.com/mpilquist) [GitHub](https://github.com/mpilquist) -- Pavel Chlupacek [GitHub](https://github.com/pchlupacek) -- Fabio Labella [GitHub](https://github.com/systemfw) - From b4a42dab1097507740042b9c1735d9111f218af6 Mon Sep 17 00:00:00 2001 From: mpilquist Date: Thu, 28 Mar 2024 08:41:01 -0400 Subject: [PATCH 42/43] Fix #3415 - fromIterator looping infinitely --- core/shared/src/main/scala/fs2/Stream.scala | 10 +++++++--- .../src/test/scala/fs2/StreamCombinatorsSuite.scala | 10 ++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/core/shared/src/main/scala/fs2/Stream.scala b/core/shared/src/main/scala/fs2/Stream.scala index 78a3c5f8d5..d1aea979a7 100644 --- a/core/shared/src/main/scala/fs2/Stream.scala +++ b/core/shared/src/main/scala/fs2/Stream.scala @@ -3646,9 +3646,13 @@ object Stream extends StreamLowPriority { def getNextChunk(i: Iterator[A]): F[Option[(Chunk[A], Iterator[A])]] = F.suspend(hint) { - i.take(chunkSize).toVector - }.map { s => - if (s.isEmpty) None else Some((Chunk.from(s), i)) + val bldr = Vector.newBuilder[A] + var cnt = 0 + while (cnt < chunkSize && i.hasNext) { + bldr += i.next() + cnt += 1 + } + if (cnt == 0) None else Some((Chunk.from(bldr.result()), i)) } Stream.unfoldChunkEval(iterator)(getNextChunk) diff --git a/core/shared/src/test/scala/fs2/StreamCombinatorsSuite.scala b/core/shared/src/test/scala/fs2/StreamCombinatorsSuite.scala index 0a9de56988..9feb2d65d9 100644 --- a/core/shared/src/test/scala/fs2/StreamCombinatorsSuite.scala +++ b/core/shared/src/test/scala/fs2/StreamCombinatorsSuite.scala @@ -704,16 +704,18 @@ class StreamCombinatorsSuite extends Fs2Suite { } test("fromIterator") { - forAllF { (x: List[Int], cs: Int) => + // Note: important to use Vector here and not List in order to prevent https://github.com/typelevel/fs2/issues/3415 + forAllF { (x: Vector[Int], cs: Int) => val chunkSize = (cs % 4096).abs + 1 - Stream.fromIterator[IO](x.iterator, chunkSize).assertEmits(x) + Stream.fromIterator[IO](x.iterator, chunkSize).assertEmits(x.toList) } } test("fromBlockingIterator") { - forAllF { (x: List[Int], cs: Int) => + // Note: important to use Vector here and not List in order to prevent https://github.com/typelevel/fs2/issues/3415 + forAllF { (x: Vector[Int], cs: Int) => val chunkSize = (cs % 4096).abs + 1 - Stream.fromBlockingIterator[IO](x.iterator, chunkSize).assertEmits(x) + Stream.fromBlockingIterator[IO](x.iterator, chunkSize).assertEmits(x.toList) } } From becbd2e74922ff6562f53746d6e5ee6b735f7bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Miguel=20Mej=C3=ADa=20Su=C3=A1rez?= Date: Fri, 29 Mar 2024 10:24:35 -0500 Subject: [PATCH 43/43] Add benchmark FlowInterop.fastPublisher --- .../fs2/benchmark/FlowInteropBenchmark.scala | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 benchmark/src/main/scala/fs2/benchmark/FlowInteropBenchmark.scala diff --git a/benchmark/src/main/scala/fs2/benchmark/FlowInteropBenchmark.scala b/benchmark/src/main/scala/fs2/benchmark/FlowInteropBenchmark.scala new file mode 100644 index 0000000000..335dda7d6f --- /dev/null +++ b/benchmark/src/main/scala/fs2/benchmark/FlowInteropBenchmark.scala @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2013 Functional Streams for Scala + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package fs2 +package benchmark + +import cats.effect.IO +import cats.effect.unsafe.implicits.global + +import org.openjdk.jmh.annotations.{ + Benchmark, + BenchmarkMode, + Mode, + OutputTimeUnit, + Param, + Scope, + State +} + +import java.util.concurrent.TimeUnit +import java.util.concurrent.Flow.{Publisher, Subscriber, Subscription} + +import scala.concurrent.Future + +@State(Scope.Thread) +@BenchmarkMode(Array(Mode.Throughput)) +@OutputTimeUnit(TimeUnit.SECONDS) +class FlowInteropBenchmark { + @Param(Array("1024", "5120", "10240")) + var totalElements: Int = _ + + @Param(Array("1000")) + var iterations: Int = _ + + @Benchmark + def fastPublisher(): Unit = { + def publisher = + new Publisher[Int] { + override final def subscribe(subscriber: Subscriber[? >: Int]): Unit = + subscriber.onSubscribe( + new Subscription { + @volatile var i: Int = 0 + @volatile var canceled: Boolean = false + + override final def request(n: Long): Unit = { + Future { + var j = 0 + while (j < n && i < totalElements && !canceled) { + subscriber.onNext(i) + i += 1 + j += 1 + } + + if (i == totalElements || canceled) { + subscriber.onComplete() + } + }(global.compute) + + // Discarding the Future so it runs in the background. + () + } + + override final def cancel(): Unit = + canceled = true + } + ) + } + + val stream = + interop.flow.fromPublisher[IO](publisher, chunkSize = 512) + + val program = + stream.compile.toVector + + program.replicateA_(iterations).unsafeRunSync() + } +}