@zio.dev/zio-blocks 0.0.33 → 0.0.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/adr/2026-07-18-data-migration.md +123 -0
  2. package/guides/async-getting-started.md +687 -0
  3. package/guides/compile-time-resource-safety-with-scope.md +21 -16
  4. package/guides/getting-started-with-mux.md +1395 -0
  5. package/guides/query-dsl-extending.md +161 -102
  6. package/guides/query-dsl-fluent-builder.md +217 -157
  7. package/guides/query-dsl-reified-optics.md +12 -10
  8. package/guides/query-dsl-sql.md +640 -165
  9. package/guides/sql-checked-interpolation.md +173 -0
  10. package/guides/sql-transactions.md +286 -0
  11. package/guides/telemetry-guide.md +1130 -0
  12. package/guides/zio-schema-migration.md +29 -22
  13. package/index.md +248 -389
  14. package/package.json +1 -1
  15. package/plans/config-follow-up-prs.md +188 -0
  16. package/plans/config-pr-assessment-roadmap.md +310 -0
  17. package/reference/MuxDataFlow.jsx +250 -0
  18. package/reference/async.md +1499 -0
  19. package/reference/chunk.md +3533 -308
  20. package/reference/codegen/case-class.md +436 -0
  21. package/reference/codegen/emitter-config.md +383 -0
  22. package/reference/codegen/examples.md +664 -0
  23. package/reference/codegen/field.md +316 -0
  24. package/reference/codegen/index.md +317 -0
  25. package/reference/codegen/scala-emitter.md +392 -0
  26. package/reference/codegen/scala-file.md +276 -0
  27. package/reference/codegen/sealed-trait.md +408 -0
  28. package/reference/codegen/type-definition.md +340 -0
  29. package/reference/codegen/type-ref.md +201 -0
  30. package/reference/combinators.md +347 -117
  31. package/reference/config/config-decoder.md +460 -0
  32. package/reference/config/config-source.md +489 -0
  33. package/reference/config/errors.md +278 -0
  34. package/reference/config/flags.md +369 -0
  35. package/reference/config/formats.md +314 -0
  36. package/reference/config/index.md +304 -0
  37. package/reference/config/rollout.md +336 -0
  38. package/reference/context.md +9 -52
  39. package/reference/data-migration.md +269 -0
  40. package/reference/datastar/attributes.md +302 -0
  41. package/reference/datastar/events.md +234 -0
  42. package/reference/datastar/index.md +256 -0
  43. package/reference/datastar/signals.md +230 -0
  44. package/reference/datastar/sse.md +295 -0
  45. package/reference/datastar.md +346 -0
  46. package/reference/docs.md +1461 -345
  47. package/reference/endpoint/auth-type.md +146 -0
  48. package/reference/endpoint/bulk-creation.md +96 -0
  49. package/reference/endpoint/endpoint.md +297 -0
  50. package/reference/endpoint/http-codec.md +249 -0
  51. package/reference/endpoint/index.md +745 -0
  52. package/reference/endpoint/path-codec.md +225 -0
  53. package/reference/endpoint/route-pattern.md +194 -0
  54. package/reference/endpoint/route-tree.md +111 -0
  55. package/reference/endpoint/segment-codec.md +199 -0
  56. package/reference/html.md +1424 -0
  57. package/reference/htmx/attribute-values.md +359 -0
  58. package/reference/htmx/hx-encoding.md +111 -0
  59. package/reference/htmx/hx-params.md +204 -0
  60. package/reference/htmx/hx-swap.md +276 -0
  61. package/reference/htmx/hx-sync.md +251 -0
  62. package/reference/htmx/hx-target.md +314 -0
  63. package/reference/htmx/hx-trigger.md +457 -0
  64. package/reference/htmx/hx-url-update.md +239 -0
  65. package/reference/htmx/index.md +807 -0
  66. package/reference/htmx/response-headers.md +240 -0
  67. package/reference/http-model/headers.md +735 -0
  68. package/reference/http-model/index.md +49 -0
  69. package/reference/http-model/model.md +1517 -0
  70. package/reference/http-model/schema-codecs.md +522 -0
  71. package/reference/http-model/schema.md +750 -0
  72. package/reference/http-model/server-sent-event.md +341 -0
  73. package/reference/jwt.md +195 -0
  74. package/reference/maybe.md +943 -0
  75. package/reference/media-type.md +2 -2
  76. package/reference/mux.md +254 -0
  77. package/reference/mux.mdx +828 -0
  78. package/reference/openapi.md +1351 -0
  79. package/reference/projection.md +654 -0
  80. package/reference/resource-management/defer-handle.md +1 -1
  81. package/reference/resource-management/resource.md +31 -98
  82. package/reference/resource-management/scope.md +28 -220
  83. package/reference/resource-management/wire.md +5 -55
  84. package/reference/ringbuffer/MpmcDiagram.jsx +717 -0
  85. package/reference/ringbuffer/MpscDiagram.jsx +618 -0
  86. package/reference/ringbuffer/SpmcDiagram.jsx +680 -0
  87. package/reference/ringbuffer/SpscDiagram.jsx +677 -0
  88. package/reference/ringbuffer/advanced.mdx +109 -0
  89. package/reference/ringbuffer/index.mdx +145 -0
  90. package/reference/ringbuffer/mpmc.mdx +185 -0
  91. package/reference/ringbuffer/mpsc.mdx +164 -0
  92. package/reference/ringbuffer/spmc.mdx +108 -0
  93. package/reference/ringbuffer/spsc.mdx +416 -0
  94. package/reference/{allows.md → schema/allows.md} +4 -100
  95. package/reference/{binding-resolver.md → schema/binding-resolver.md} +1 -1
  96. package/reference/{binding.md → schema/binding.md} +3 -4
  97. package/reference/schema/built-in-codecs/avro.md +451 -0
  98. package/reference/schema/built-in-codecs/bson.md +510 -0
  99. package/reference/schema/built-in-codecs/csv.md +564 -0
  100. package/reference/schema/built-in-codecs/index.md +77 -0
  101. package/reference/schema/built-in-codecs/json/index.md +295 -0
  102. package/reference/schema/built-in-codecs/json/json-config.md +217 -0
  103. package/reference/{json-patch.md → schema/built-in-codecs/json/json-patch.md} +5 -5
  104. package/reference/{json-schema.md → schema/built-in-codecs/json/json-schema.md} +14 -47
  105. package/reference/schema/built-in-codecs/json/json-selection.md +322 -0
  106. package/reference/{json.md → schema/built-in-codecs/json/json.md} +32 -64
  107. package/reference/schema/built-in-codecs/messagepack.md +508 -0
  108. package/reference/schema/built-in-codecs/thrift.md +433 -0
  109. package/reference/schema/built-in-codecs/toon.md +1078 -0
  110. package/reference/{xml.md → schema/built-in-codecs/xml.md} +13 -9
  111. package/reference/schema/built-in-codecs/yaml.md +552 -0
  112. package/reference/{codec.md → schema/codec.md} +11 -11
  113. package/reference/{dynamic-optic.md → schema/dynamic-optic.md} +196 -5
  114. package/reference/{dynamic-schema.md → schema/dynamic-schema.md} +8 -8
  115. package/reference/schema/format.md +92 -0
  116. package/reference/schema/index.md +52 -0
  117. package/reference/schema/migration.md +297 -0
  118. package/reference/{modifier.md → schema/modifier.md} +58 -7
  119. package/reference/{optics.md → schema/optics.md} +2 -2
  120. package/reference/{patch.md → schema/patch.md} +1 -1
  121. package/{path-interpolator.md → reference/schema/path-interpolator.md} +167 -72
  122. package/reference/schema/reflect-transformer.md +140 -0
  123. package/reference/{schema-evolution → schema/schema-evolution}/as.md +8 -8
  124. package/reference/{schema-evolution → schema/schema-evolution}/index.md +2 -2
  125. package/reference/{schema-evolution → schema/schema-evolution}/into.md +8 -8
  126. package/reference/{schema-expr.md → schema/schema-expr.md} +110 -175
  127. package/reference/schema/schema-search.md +263 -0
  128. package/reference/{schema.md → schema/schema.md} +22 -2
  129. package/reference/{structural-types.md → schema/structural-types.md} +1 -1
  130. package/reference/{type-class-derivation.md → schema/type-class-derivation.md} +63 -1
  131. package/reference/smithy.md +1032 -0
  132. package/reference/sql/db-codec-deriver.md +71 -0
  133. package/reference/sql/db-codec.md +687 -0
  134. package/reference/sql/db-con.md +271 -0
  135. package/reference/sql/db-connection.md +153 -0
  136. package/reference/sql/db-param-writer.md +77 -0
  137. package/reference/sql/db-param.md +66 -0
  138. package/reference/sql/db-result-reader.md +148 -0
  139. package/reference/sql/db-tx.md +114 -0
  140. package/reference/sql/db-value.md +41 -0
  141. package/reference/sql/ddl.md +85 -0
  142. package/reference/sql/frag.md +288 -0
  143. package/reference/sql/index.md +341 -0
  144. package/reference/sql/repo.md +600 -0
  145. package/reference/sql/sql-dialect.md +73 -0
  146. package/reference/sql/sql-logger.md +62 -0
  147. package/reference/sql/sql-name-mapper.md +70 -0
  148. package/reference/sql/table-metadata.md +134 -0
  149. package/reference/sql/table.md +448 -0
  150. package/reference/sql/transactor-zio.md +399 -0
  151. package/reference/sql/transactor.md +363 -0
  152. package/reference/sql-zio.md +112 -0
  153. package/reference/streams/core/index.md +32 -0
  154. package/reference/streams/core/pipeline.md +854 -0
  155. package/reference/streams/core/sink.md +1404 -0
  156. package/reference/streams/core/stream.md +3236 -0
  157. package/reference/streams/execution-and-compatibility/async-execution.md +822 -0
  158. package/reference/streams/execution-and-compatibility/index.md +35 -0
  159. package/reference/streams/execution-and-compatibility/platform-differences.md +297 -0
  160. package/reference/streams/execution-and-compatibility/scala-2-compatibility.md +88 -0
  161. package/reference/streams/execution-and-compatibility/zero-boxing.md +393 -0
  162. package/reference/streams/index.md +726 -0
  163. package/reference/streams/primitives/index.md +30 -0
  164. package/reference/streams/primitives/reader.md +1992 -0
  165. package/reference/streams/primitives/writer.md +1201 -0
  166. package/reference/telemetry/common/any-value.md +90 -0
  167. package/reference/telemetry/common/attribute-key.md +87 -0
  168. package/reference/telemetry/common/attributes.md +118 -0
  169. package/reference/telemetry/common/index.md +39 -0
  170. package/reference/telemetry/common/instrumentation-scope.md +24 -0
  171. package/reference/telemetry/common/resource.md +34 -0
  172. package/reference/telemetry/index.md +311 -0
  173. package/reference/telemetry/logging/index.md +197 -0
  174. package/reference/telemetry/logging/log-enrichment.md +72 -0
  175. package/reference/telemetry/logging/log-formatter.md +100 -0
  176. package/reference/telemetry/logging/log-record-processor.md +56 -0
  177. package/reference/telemetry/logging/log-record.md +44 -0
  178. package/reference/telemetry/logging/log-writer.md +64 -0
  179. package/reference/telemetry/logging/logger-provider.md +142 -0
  180. package/reference/telemetry/logging/logger.md +83 -0
  181. package/reference/telemetry/logging/severity.md +62 -0
  182. package/reference/telemetry/metrics/index.md +150 -0
  183. package/reference/telemetry/metrics/instruments.md +183 -0
  184. package/reference/telemetry/metrics/labeled-instruments.md +74 -0
  185. package/reference/telemetry/metrics/meter-provider.md +76 -0
  186. package/reference/telemetry/metrics/meter.md +98 -0
  187. package/reference/telemetry/metrics/metric-data.md +57 -0
  188. package/reference/telemetry/otel/custom-exporter.md +216 -0
  189. package/reference/telemetry/otel/index.md +212 -0
  190. package/reference/telemetry/tracing/index.md +155 -0
  191. package/reference/telemetry/tracing/sampler.md +89 -0
  192. package/reference/telemetry/tracing/span-builder.md +57 -0
  193. package/reference/telemetry/tracing/span-context.md +39 -0
  194. package/reference/telemetry/tracing/span-data.md +32 -0
  195. package/reference/telemetry/tracing/span-kind.md +55 -0
  196. package/reference/telemetry/tracing/span-processor.md +53 -0
  197. package/reference/telemetry/tracing/span-status.md +47 -0
  198. package/reference/telemetry/tracing/span.md +117 -0
  199. package/reference/telemetry/tracing/tracer-provider.md +91 -0
  200. package/reference/telemetry/tracing/tracer.md +52 -0
  201. package/reference/typeid.md +5 -83
  202. package/sidebars.js +376 -43
  203. package/undocumented-report.md +528 -270
  204. package/reference/formats.md +0 -694
  205. package/reference/http-model.md +0 -1716
  206. package/reference/streams.md +0 -989
  207. package/ringbuffer.md +0 -249
  208. /package/reference/{json-differ.md → schema/built-in-codecs/json/json-differ.md} +0 -0
  209. /package/reference/{dynamic-value.md → schema/dynamic-value.md} +0 -0
  210. /package/reference/{lazy.md → schema/lazy.md} +0 -0
  211. /package/reference/{reflect.md → schema/reflect.md} +0 -0
  212. /package/reference/{registers.md → schema/registers.md} +0 -0
  213. /package/reference/{schema-error.md → schema/schema-error.md} +0 -0
  214. /package/reference/{syntax.md → schema/syntax.md} +0 -0
  215. /package/reference/{validation.md → schema/validation.md} +0 -0
@@ -0,0 +1,1992 @@
1
+ ---
2
+ id: reader
3
+ title: "Reader"
4
+ sidebar_label: "Reader"
5
+ description: "The pull-based source behind ZIO Blocks streams: the SyncReader and AsyncReader kinds, the pull protocol, and the custom reader contracts."
6
+ keywords:
7
+ - "Pull-Based Streaming"
8
+ - "Reader Kinds"
9
+ - "Asynchronous Reading"
10
+ - "Sentinel Protocol"
11
+ - "Reader"
12
+ - "AsyncNioReaders"
13
+ - "ReadableStreamReaders"
14
+ ---
15
+
16
+ `Reader[+Elem]` is the **pull-based source that powers ZIO Blocks streams**. When you call a terminal operation like `stream.run(sink)`, the stream compiles into a `Reader`, which yields values one at a time on demand until closed.
17
+
18
+ `Reader` has two library-provided kinds, `Reader.SyncReader[Elem]` and `Reader.AsyncReader[Elem]`. A synchronous reader's `read` and `close` return directly; an asynchronous reader's pull and lifecycle methods return `Async`. Most users never interact with either kind directly, but understanding them clarifies how streams work internally.
19
+
20
+ The compilation and execution flow:
21
+
22
+ ```
23
+ Stream[E, A] ──(compile)──> Reader[A]
24
+ │
25
+ └─(drain via Sink)──> Either[E, Z]
26
+ ```
27
+
28
+ `Reader`:
29
+ - Is lazy and pull-based — `Stream` transformations don't run until `read()` is called, running in constant space one element at a time
30
+ - Is a single-consumer cursor — do not share a `SyncReader` between threads or overlap operations on an `AsyncReader`
31
+ - Uses a sentinel protocol where callers specify the end-of-stream value; all eight JVM primitives have exact physical pull methods: `readBoolean`, `readByte`, `readChar`, `readShort`, `readInt`, `readLong`, `readFloat`, and `readDouble`. The `Long` and `Double` lanes are the exception: no sentinel is safe there, so they detect end of stream by the count returned from `readLongs` / `readDoubles`.
32
+ - Dispatches on `Reader#jvmType`, which describes the reader's physical representation and therefore the exact pull method it supports, not merely the static or logical element type
33
+ - Is the compilation target of `Stream` — when a stream runs, it becomes a `Reader`
34
+ - Transfers lifecycle responsibility explicitly: terminals and bracketed APIs close their owned reader, while callers of `startAsync` own the returned reader and must await `close()`
35
+ - Supports composition by chaining readers through transformations without materializing intermediate data
36
+
37
+ Here is the core `Reader` interface with the most essential methods:
38
+
39
+ ```scala
40
+ abstract class Reader[+Elem]
41
+
42
+ abstract class Reader.SyncReader[+Elem] extends Reader[Elem] {
43
+ def read[A >: Elem](sentinel: A): A
44
+ def readAll[A >: Elem](): Chunk[A]
45
+ def readN[A >: Elem](n: Int): Chunk[A]
46
+ def readUpToN[A >: Elem](n: Int): Chunk[A]
47
+ def isClosed: Boolean
48
+ def readable(): Boolean
49
+ def close(): Unit
50
+ def toAsync: Reader.AsyncReader[Elem]
51
+ }
52
+
53
+ abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
54
+ def read[A >: Elem](sentinel: A): Async[A]
55
+ def readAll[A >: Elem](): Async[Chunk[A]]
56
+ def readN[A >: Elem](n: Int): Async[Chunk[A]]
57
+ def readUpToN[A >: Elem](n: Int): Async[Chunk[A]]
58
+ def isClosed: Async[Boolean]
59
+ def readable(): Async[Boolean]
60
+ def close(): Async[Unit]
61
+ // JVM only: def toSync: Reader.SyncReader[Elem]
62
+ }
63
+ ```
64
+
65
+ The root contains only kind-independent composition and metadata (`++`, `concat`, `concatAsync`, `withReleaseAsync`, and `jvmType`); it cannot be pulled, queried, or closed directly. Those operations belong to one of the two reader kinds, described in [The reader union](#the-reader-union). Every primitive, bulk, lifecycle, and pushdown method on `AsyncReader` has the same parameters as its `SyncReader` counterpart but returns its result in `Async`; [Asynchronous reading](#asynchronous-reading) is the full member list. The eight physical primitive methods are `readBoolean`, `readByte`, `readChar`, `readShort`, `readInt`, `readLong`, `readFloat`, and `readDouble`; a primitive `jvmType` is a contract that the corresponding method works, even when covariance has widened the reader's static element type.
66
+
67
+ An `AsyncReader` permits one active operation at a time, and closing it is the owner's responsibility. `SyncReader#toAsync` and the JVM-only `AsyncReader#toSync` move a reader between the two kinds without copying it.
68
+
69
+ ## Quick Showcase
70
+
71
+ Here's how to create and drain a `Reader`:
72
+
73
+ ```scala
74
+ import zio.blocks.streams.io.Reader
75
+ import zio.blocks.chunk.Chunk
76
+ import scala.collection.mutable.Buffer
77
+
78
+ val r = Reader.fromChunk(Chunk(1, 2, 3, 4, 5))
79
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@56d06418
80
+ val collected = Buffer[Int]()
81
+ // collected: Buffer[Int] = ArrayBuffer(1, 2, 3, 4, 5)
82
+
83
+ // Pull elements until sentinel
84
+ def drainAll(): Unit = {
85
+ val elem = r.read(-1)
86
+ if (elem != -1) {
87
+ collected += elem
88
+ drainAll()
89
+ }
90
+ }
91
+ drainAll()
92
+
93
+ println(s"Collected: $collected")
94
+ // Collected: ArrayBuffer(1, 2, 3, 4, 5)
95
+ ```
96
+
97
+ ## Motivation
98
+
99
+ Imagine you're processing a massive CSV file—millions of rows of customer data. Your first instinct is to load it all into memory as a `List[Row]`, transform it, filter it, and then write the results. This works fine for small files, but one day someone feeds you a 50GB dataset and your application crashes with `OutOfMemoryError`. You've hit the fundamental problem of eager evaluation: **you must load everything before you can do anything**, and if the data is bigger than available memory, you're stuck.
100
+
101
+ Even if you manage to fit the data in memory, you've paid the startup cost upfront. If your pipeline only needs the first 100 rows to produce a result, you've wasted time and energy materializing the other millions. And if something fails partway through—a database connection drops, a file is corrupted—you've already consumed resources and may have inconsistencies to clean up.
102
+
103
+ The streaming intuition is different: instead of pulling all data at once, what if the consumer asked the producer "give me the next element?" one at a time? This way, you never hold more than one element in memory, you only do work on elements you actually use, and you can stop immediately when you have enough.
104
+
105
+ `Reader` embodies this pull-based philosophy. Rather than materializing a `List`, a `Stream` compiles down to a `Reader`—a stateful object that produces one element each time you call `read()`. The consumer (a `Sink`) drives the pace: it calls `read()` when ready, and the `Reader` computes and returns the next value. When the stream is exhausted, `Reader` returns a sentinel—a special value you provide—signaling "no more data." No exceptions, no null, no boxing overhead.
106
+
107
+ `Reader` shines when you're processing large, unbounded, or expensive-to-produce data sources: database result sets, network streams, log files, sensor data, or any pipeline where memory or time efficiency matters. Instead of hoping your data fits in memory, you pay a constant, predictable cost per element.
108
+
109
+ ## The Reader Union
110
+
111
+ `Reader[+Elem]` is the root of two kinds. It declares composition and one piece of metadata, and nothing that pulls, queries, or closes:
112
+
113
+ ```scala
114
+ abstract class Reader[+Elem] {
115
+ def ++[Elem2 >: Elem](next: => Reader[Elem2]): Reader[Elem2]
116
+ def concat[Elem2 >: Elem](next: () => Reader[Elem2]): Reader[Elem2]
117
+ def concatAsync[Elem2 >: Elem](next: () => Async[Reader[Elem2]]): Reader.AsyncReader[Elem2]
118
+ def withReleaseAsync(release: () => Async[Unit]): Reader.AsyncReader[Elem]
119
+ def jvmType: JvmType = JvmType.AnyRef
120
+ }
121
+ ```
122
+
123
+ A value typed `Reader[A]` can be concatenated and can report its physical lane, and that is all. To read from it you must know its kind:
124
+
125
+ - `Reader.SyncReader[Elem]` holds the direct pull API — `read`, `readAll`, `readN`, `readUpToN`, the eight primitive pulls, the array transfers, `skip`, the pushdown operations, `isClosed`, `readable()`, and `close()`, each returning its result immediately.
126
+ - `Reader.AsyncReader[Elem]` mirrors that surface method for method, with every result wrapped in `Async`.
127
+
128
+ The two kinds line up one to one, so a signature written against one translates mechanically to the other:
129
+
130
+ | Member | `SyncReader` result | `AsyncReader` result |
131
+ |--------------------------------------------|---------------------|----------------------|
132
+ | `read(sentinel)` | `A` | `Async[A]` |
133
+ | `readAll()` | `Chunk[A]` | `Async[Chunk[A]]` |
134
+ | `readN(n)`, `readUpToN(n)` | `Chunk[A]` | `Async[Chunk[A]]` |
135
+ | `readInt(_sentinel)` | `Long` | `Async[Long]` |
136
+ | `readBytes(dest, offset, length)` | `Int` | `Async[Int]` |
137
+ | `isClosed` | `Boolean` | `Async[Boolean]` |
138
+ | `readable()` | `Boolean` | `Async[Boolean]` |
139
+ | `close()` | `Unit` | `Async[Unit]` |
140
+ | `skip(n)` | `Unit` | `Async[Unit]` |
141
+ | `reset()` | `Unit` | `Async[Unit]` |
142
+ | `setLimit(n)`, `setRepeat()`, `setSkip(n)` | `Boolean` | `Async[Boolean]` |
143
+
144
+ Which kind a stream materializes as is decided once, when the graph compiles: a fully synchronous graph produces a `SyncReader`, and a graph with any asynchronous node produces an `AsyncReader`. See [Asynchronous Stream Execution](../execution-and-compatibility/async-execution.md#one-stream-type-two-execution-modes) for that decision.
145
+
146
+ One caveat about the root: `Reader` is declared `abstract class Reader[+Elem]`, not `sealed`. Every reader the library hands you is a `SyncReader` or an `AsyncReader`, and code may rely on that in practice — what it cannot rely on is the compiler proving a `match` over the two kinds exhaustive, so write such a match with a fallback case.
147
+
148
+ ### Mixed-kind Composition
149
+
150
+ Concatenation keeps both kinds usable through the same `++` and `concat` names. `SyncReader` adds a pair of overloads that are narrowed to synchronous arguments and disambiguated from the inherited ones by a `DummyImplicit` parameter:
151
+
152
+ ```scala
153
+ abstract class Reader.SyncReader[+Elem] extends Reader[Elem] {
154
+ final def ++[Elem2 >: Elem](next: => SyncReader[Elem2])(implicit dummy: DummyImplicit): SyncReader[Elem2]
155
+ final def concat[Elem2 >: Elem](next: () => SyncReader[Elem2])(implicit dummy: DummyImplicit): SyncReader[Elem2]
156
+
157
+ final override def ++[Elem2 >: Elem](next: => Reader[Elem2]): AsyncReader[Elem2]
158
+ final override def concat[Elem2 >: Elem](next: () => Reader[Elem2]): AsyncReader[Elem2]
159
+ }
160
+ ```
161
+
162
+ The rule that falls out is simple: **synchronous plus synchronous stays synchronous; every other combination widens to `AsyncReader`**. When the widening overload is chosen, the synchronous side is adapted with `toAsync` and the pair is concatenated on the asynchronous path.
163
+
164
+ ```scala
165
+ import zio.blocks.streams.io.Reader
166
+ import zio.blocks.chunk.Chunk
167
+
168
+ val sync: Reader.SyncReader[Int] = Reader.fromChunk(Chunk(1, 2))
169
+ val async: Reader.AsyncReader[Int] = Reader.fromChunk(Chunk(3, 4)).toAsync
170
+
171
+ val syncSync: Reader.SyncReader[Int] = sync ++ Reader.fromChunk(Chunk(5, 6))
172
+ val syncAsync: Reader.AsyncReader[Int] = sync ++ async
173
+ val asyncSync: Reader.AsyncReader[Int] = async ++ Reader.fromChunk(Chunk(7, 8))
174
+ val asyncAsync: Reader.AsyncReader[Int] = async ++ async
175
+ ```
176
+
177
+ Because the overloads are selected on the *static* type of the argument, a value already widened to `Reader[Int]` picks the widening overload even when it happens to hold a `SyncReader` at runtime. Keep the narrow type if you want to stay on the synchronous path.
178
+
179
+ `concatAsync` and `withReleaseAsync` are declared on the root and always produce an `AsyncReader`, whichever kind they are called on — the first because the next reader arrives inside an `Async`, the second because the release action does:
180
+
181
+ ```scala
182
+ abstract class Reader[+Elem] {
183
+ def concatAsync[Elem2 >: Elem](next: () => Async[Reader[Elem2]]): Reader.AsyncReader[Elem2]
184
+ def withReleaseAsync(release: () => Async[Unit]): Reader.AsyncReader[Elem]
185
+ }
186
+ ```
187
+
188
+ ### Converting Between Kinds
189
+
190
+ Two adapters move a reader across the split:
191
+
192
+ ```scala
193
+ abstract class Reader.SyncReader[+Elem] extends Reader[Elem] {
194
+ final def toAsync: Reader.AsyncReader[Elem]
195
+ }
196
+
197
+ abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
198
+ final def toSync: Reader.SyncReader[Elem] // JVM only
199
+ }
200
+ ```
201
+
202
+ `toAsync` is available on every platform. `toSync` is supplied by a JVM-only platform trait; on Scala.js that trait is empty, so the method does not exist and shared code cannot call it. [Platform Differences](../execution-and-compatibility/platform-differences.md#availability-matrix) has the full capability split.
203
+
204
+ Both adapters are lifecycle-preserving views rather than copies. The adapter wraps the original reader, so the two sides share one position and one lifecycle: closing either one closes the underlying source, and consuming through both interleaves pulls on the same cursor. Pick one view and drive the reader through it.
205
+
206
+ Both also unwrap a round trip instead of stacking. Calling `toAsync` on a reader that is itself the synchronous view of an `AsyncReader` returns that original asynchronous reader, and `toSync` on a synchronous reader's asynchronous view returns the original synchronous one. Converting back and forth therefore costs nothing and never builds a tower of adapters.
207
+
208
+ `toAsync` is the adapter to reach for when a helper is written against `Reader.AsyncReader` — the kind that compiles on both platforms — and the reader at the call site happens to be synchronous:
209
+
210
+ ```scala
211
+ import zio.blocks.async._
212
+ import zio.blocks.streams.io.Reader
213
+
214
+ def firstByte(reader: Reader.AsyncReader[Byte]): Async[Int] = reader.readByte()
215
+
216
+ def firstByteOfSync(reader: Reader.SyncReader[Byte]): Async[Int] = firstByte(reader.toAsync)
217
+ ```
218
+
219
+ What it does not do is make blocking work non-blocking. Driving the view still runs the synchronous reader's pulls on the driving thread.
220
+
221
+ `toSync` runs the other way. Use it at a JVM edge — an `InputStream`-shaped API, a legacy protocol loop — and not in code that cross-builds:
222
+
223
+ ```scala
224
+ import zio.blocks.streams.io.Reader
225
+
226
+ def firstByteBlocking(reader: Reader.AsyncReader[Byte]): Int = {
227
+ val sync = reader.toSync
228
+ try sync.readByte()
229
+ finally sync.close()
230
+ }
231
+ ```
232
+
233
+ Unlike `toAsync`, it has hazards that belong at the call site:
234
+
235
+ :::warning[`toSync` blocks, serializes, and interrupts]
236
+ Every pull blocks the calling thread until the asynchronous work settles. Only one pull runs at a time: a second thread entering the view waits until the first pull completes, so the view is a serialization point, not a way to share a reader. Calling `close()` from another thread interrupts the thread parked in a pull — that pull then returns its closed value (`-1`, the sentinel, or an empty chunk) instead of the value it was waiting for. Closing the view also closes the underlying asynchronous reader. After a close, the control operations — `reset()`, `setLimit`, `setRepeat`, `setSkip`, and `skip` — throw `IOException("Reader is closed")`.
237
+ :::
238
+
239
+ The rule to carry away is that `toSync` belongs at JVM edges, not in shared code.
240
+
241
+ ## Construction
242
+
243
+ Several ways to create a `Reader`, from predefined singletons to collections and I/O sources:
244
+
245
+ Every companion constructor states its kind in its return type, so you never have to guess which engine a hand-built reader will drive. Factories such as `closed`, `fromChunk`, `fromIterable`, `fromRange`, `single`, `repeat`, and `unfold` are declared to return `SyncReader` — `def fromRange(range: Range): SyncReader[Int]`, and so on. `unfoldAsync` is the one native asynchronous constructor and returns an `AsyncReader`. `repeated` is overloaded three ways and preserves whether its input is synchronous or asynchronous. Composition also preserves asynchronous work: `concatAsync` lazily acquires the next reader and `withReleaseAsync` awaits asynchronous cleanup. Asynchronous children are supported throughout the reader graph.
246
+
247
+ ### Creating Predefined Readers
248
+
249
+ `Reader.closed` — An already-closed reader that emits no elements. Useful as a base case or for empty streams:
250
+
251
+ ```scala
252
+ object Reader {
253
+ def closed: Reader.SyncReader[Nothing]
254
+ }
255
+ ```
256
+
257
+ Here's how to create and use a closed reader:
258
+
259
+ ```scala
260
+ import zio.blocks.streams.io.Reader
261
+
262
+ val r = Reader.closed
263
+ // r: SyncReader[Nothing] = zio.blocks.streams.io.Reader$ClosedReader$@4dad5488
264
+ println(r.isClosed) // true
265
+ // true
266
+ println(r.read(-1)) // -1 (the sentinel)
267
+ // -1
268
+ ```
269
+
270
+ ### From Collections
271
+
272
+ `Reader.fromChunk` — Creates a reader backed by a [`Chunk`](../../chunk.md). Dispatches on the element type to use specialized, unboxed reads for primitives:
273
+
274
+ ```scala
275
+ object Reader {
276
+ def fromChunk[A](chunk: Chunk[A])(implicit jt: JvmType.Infer[A]): Reader.SyncReader[A]
277
+ }
278
+ ```
279
+
280
+ Create a reader from a chunk and drain its elements:
281
+
282
+ ```scala
283
+ import zio.blocks.streams.io.Reader
284
+ import zio.blocks.chunk.Chunk
285
+
286
+ val chunk = Chunk(10, 20, 30)
287
+ // chunk: Chunk[Int] = IndexedSeq(10, 20, 30)
288
+ val r = Reader.fromChunk(chunk)
289
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@31de1289
290
+
291
+ def drain(): Unit = {
292
+ val v = r.read(-1)
293
+ if (v != -1) {
294
+ println(v)
295
+ drain()
296
+ }
297
+ }
298
+ drain()
299
+ // 10
300
+ // 20
301
+ // 30
302
+ // Output: 10, 20, 30
303
+ ```
304
+
305
+ `Reader.fromIterable` — Creates a reader from any `Iterable`. Works with lists, sets, vectors, and other collections:
306
+
307
+ ```scala
308
+ object Reader {
309
+ def fromIterable[A](it: Iterable[A])(implicit jt: JvmType.Infer[A]): Reader.SyncReader[A]
310
+ }
311
+ ```
312
+
313
+ Create a reader from a list and consume its elements:
314
+
315
+ ```scala
316
+ import zio.blocks.streams.io.Reader
317
+
318
+ val list = List("a", "b", "c")
319
+ // list: List[String] = List("a", "b", "c")
320
+ val r = Reader.fromIterable(list)
321
+ // r: SyncReader[String] = zio.blocks.streams.io.Reader$FromIterable@555aefcc
322
+
323
+ def drain(): Unit = {
324
+ val v = r.read(null)
325
+ if (v != null) {
326
+ println(v)
327
+ drain()
328
+ }
329
+ }
330
+ drain()
331
+ // a
332
+ // b
333
+ // c
334
+ // Output: a, b, c
335
+ ```
336
+
337
+ `Reader.fromRange` — Creates a reader from a Scala `Range`. Optimized for integer ranges without allocation:
338
+
339
+ ```scala
340
+ object Reader {
341
+ def fromRange(range: Range): Reader.SyncReader[Int]
342
+ }
343
+ ```
344
+
345
+ Create a reader from a range and drain the integers:
346
+
347
+ ```scala
348
+ import zio.blocks.streams.io.Reader
349
+
350
+ val r = Reader.fromRange(1 to 5)
351
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromRange@57d0e296
352
+
353
+ def drain(): Unit = {
354
+ val v = r.read(-1)
355
+ if (v != -1) {
356
+ println(v)
357
+ drain()
358
+ }
359
+ }
360
+ drain()
361
+ // 1
362
+ // 2
363
+ // 3
364
+ // 4
365
+ // 5
366
+ // Output: 1, 2, 3, 4, 5
367
+ ```
368
+
369
+ ### From I/O
370
+
371
+ `Reader.fromInputStream` — Wraps a `java.io.InputStream` as a `SyncReader[Byte]`. `readByte()` exposes the unsigned `0`–`255` view and reserves `-1` exclusively for EOF; ordinary element pulls retain `Byte` values:
372
+
373
+ ```scala
374
+ object Reader {
375
+ def fromInputStream(is: InputStream): Reader.SyncReader[Byte]
376
+ }
377
+ ```
378
+
379
+ `Reader.fromReader` — Wraps a `java.io.Reader` as a `SyncReader[Char]` for character-based I/O:
380
+
381
+ ```scala
382
+ object Reader {
383
+ def fromReader(r: java.io.Reader): Reader.SyncReader[Char]
384
+ }
385
+ ```
386
+
387
+ `NioReaders` is the `java.nio` counterpart, and it is synchronous throughout. It has no asynchronous twins, and the reason is in the type it wraps: `ReadableByteChannel#read` blocks the calling thread. No wrapper can make it non-blocking, so presenting its result as an `AsyncReader` would have promised something the channel cannot deliver. The two objects split by capability — `AsyncNioReaders` for channels that implement the JDK's asynchronous read protocol, `NioReaders` for the blocking ones and for `ByteBuffer`s.
388
+
389
+ Every `NioReaders` factory states its kind in its return type:
390
+
391
+ ```scala
392
+ object NioReaders {
393
+ def fromByteBuffer(buf: ByteBuffer): Reader.SyncReader[Byte]
394
+ def fromByteBufferDouble(buf: ByteBuffer): Reader.SyncReader[Double]
395
+ def fromByteBufferFloat(buf: ByteBuffer): Reader.SyncReader[Float]
396
+ def fromByteBufferInt(buf: ByteBuffer): Reader.SyncReader[Int]
397
+ def fromByteBufferLong(buf: ByteBuffer): Reader.SyncReader[Long]
398
+ def fromChannel(ch: ReadableByteChannel, bufSize: Int = 8192): Reader.SyncReader[Byte]
399
+ }
400
+ ```
401
+
402
+ These factories return `Reader.SyncReader[Byte]` rather than the root `Reader[Byte]`, which is what makes pulling and closing available on the result: those members belong to the reader kinds, not to the root type. Two details of this object are worth noting at a call site: its channel factory names the buffer parameter `bufSize`, where the asynchronous one names it `bufferSize`, and there is no public unmanaged channel variant here — `NioReaders.fromChannel` always owns the channel it wraps.
403
+
404
+ File bytes are reachable only through this synchronous side: a `java.nio.channels.FileChannel` is a `ReadableByteChannel`, so `NioReaders.fromChannel` accepts it. That reader blocks, and `SyncReader#toAsync` does not change it — the resulting asynchronous reader still blocks the thread that drives it.
405
+
406
+ ### From Native Asynchronous Sources
407
+
408
+ Each platform ships a small set of factories that turn a native asynchronous byte source into a `Reader.AsyncReader[Byte]`. On the JVM that source is a `java.nio.channels.AsynchronousByteChannel`; on Scala.js it is a Web Streams API `ReadableStream`. Once wrapped, the result is an ordinary asynchronous reader: pull from it by hand, or hand it to `Stream.fromReader` and run the pipeline with a `*Async` terminal.
409
+
410
+ Six factories exist across the two platforms, all of them returning `Reader.AsyncReader[Byte]` on the `JvmType.Byte` lane. They differ in what they wrap and in who owns the native source once the reader closes:
411
+
412
+ | Factory | Platform | Native source | On reader `close()` |
413
+ |-----------------------------------------------------|----------|-----------------------------|--------------------------------------------|
414
+ | `AsyncNioReaders.fromChannel` | JVM | `AsynchronousByteChannel` | Closes the channel |
415
+ | `AsyncNioReaders.fromChannelUnmanaged` | JVM | `AsynchronousByteChannel` | Leaves the channel open |
416
+ | `AsyncNioReaders.fromSocket` | JVM | `AsynchronousSocketChannel` | Closes the socket |
417
+ | `AsyncNioReaders.fromSocketUnmanaged` | JVM | `AsynchronousSocketChannel` | Leaves the socket open |
418
+ | `ReadableStreamReaders.fromReadableStream` | Scala.js | `ReadableStream` | Cancels the stream, then releases the lock |
419
+ | `ReadableStreamReaders.fromReadableStreamUnmanaged` | Scala.js | `ReadableStream` | Releases the lock, never cancels |
420
+
421
+ The two adapters follow the same rules, so a cross-platform consumer sees the same behaviour from either one:
422
+
423
+ | Situation | JVM `AsyncNioReaders` | Scala.js `ReadableStreamReaders` |
424
+ |-----------------------------|------------------------------------------------|--------------------------------------------|
425
+ | A delivery carries no bytes | The buffer is cleared and the read resubmitted | The chunk is skipped and `read()` reissued |
426
+ | End of stream | A negative completion count | `done = true` on the read result |
427
+ | A source failure | `IOException`, trusted | A rejected promise, trusted |
428
+ | Read buffer size | `bufferSize`, default 8192 | Not configurable |
429
+
430
+ Neither module is a general native-I/O layer. `AsyncNioReaders` reads from channels that implement the JDK's asynchronous read protocol, and `ReadableStreamReaders` reads from a browser or Node byte stream. Everything else — files on the JVM, non-byte sources on Scala.js — is outside what these factories accept.
431
+
432
+ #### JVM: `AsyncNioReaders`
433
+
434
+ `AsyncNioReaders` is the JVM factory object for genuinely non-blocking reads. Its four factories divide along two axes: the type of the native source, and whether the reader owns it.
435
+
436
+ Both channel factories wrap an `AsynchronousByteChannel` and take the read buffer size as a defaulted second parameter:
437
+
438
+ ```scala
439
+ object AsyncNioReaders {
440
+ def fromChannel(channel: AsynchronousByteChannel, bufferSize: Int = 8192): Reader.AsyncReader[Byte]
441
+ def fromChannelUnmanaged(channel: AsynchronousByteChannel, bufferSize: Int = 8192): Reader.AsyncReader[Byte]
442
+ }
443
+ ```
444
+
445
+ `bufferSize` is the capacity of the single `ByteBuffer` the reader refills from the channel, and it is validated eagerly: a value of zero or less throws `IllegalArgumentException` with the message `requirement failed: bufferSize must be positive` from the factory call itself, not from the first pull.
446
+
447
+ To lift a channel into a stream and run it with a cross-platform terminal:
448
+
449
+ ```scala
450
+ import zio.blocks.async._
451
+ import zio.blocks.chunk.Chunk
452
+ import zio.blocks.streams._
453
+
454
+ import java.nio.channels.AsynchronousByteChannel
455
+
456
+ def collect(channel: AsynchronousByteChannel): Async[Either[Nothing, Chunk[Byte]]] =
457
+ Stream.fromReader[Nothing, Byte](AsyncNioReaders.fromChannel(channel, bufferSize = 4096)).runCollectAsync
458
+ ```
459
+
460
+ Driving the reader by hand works the same way, and is what you want when the protocol is framed rather than streamed:
461
+
462
+ ```scala
463
+ import zio.blocks.async._
464
+ import zio.blocks.chunk.Chunk
465
+ import zio.blocks.streams.AsyncNioReaders
466
+ import zio.blocks.streams.io.Reader
467
+
468
+ import java.nio.channels.AsynchronousByteChannel
469
+
470
+ def header(channel: AsynchronousByteChannel): Async[Chunk[Byte]] = {
471
+ val reader: Reader.AsyncReader[Byte] = AsyncNioReaders.fromChannelUnmanaged(channel, bufferSize = 512)
472
+ reader.readN[Byte](16).flatMap(bytes => reader.close().map(_ => bytes))
473
+ }
474
+ ```
475
+
476
+ The socket pair narrows the parameter type to `AsynchronousSocketChannel`, which is the `AsynchronousByteChannel` most callers actually hold:
477
+
478
+ ```scala
479
+ object AsyncNioReaders {
480
+ def fromSocket(socket: AsynchronousSocketChannel, bufferSize: Int = 8192): Reader.AsyncReader[Byte]
481
+ def fromSocketUnmanaged(socket: AsynchronousSocketChannel, bufferSize: Int = 8192): Reader.AsyncReader[Byte]
482
+ }
483
+ ```
484
+
485
+ There is no behavioural difference to learn: `AsyncNioReaders.fromSocket` delegates to `AsyncNioReaders.fromChannel` and `AsyncNioReaders.fromSocketUnmanaged` to `AsyncNioReaders.fromChannelUnmanaged`, with the same buffer and the same ownership rule. They exist so a socket-shaped call site reads as one.
486
+
487
+ #### Managed Versus Unmanaged Ownership
488
+
489
+ Ownership is the whole of the difference between the two variants, and it is decided when you pick the factory, not later.
490
+
491
+ A managed reader — `AsyncNioReaders.fromChannel` or `AsyncNioReaders.fromSocket` — closes the underlying channel when the reader closes, and only if the channel is still open. If that channel close fails, the failure surfaces from the reader's own `close()` rather than being swallowed. An unmanaged reader releases the reader and nothing else: the channel stays open for whoever owns it, and a reader close is invisible to the rest of the program apart from the read it cancels.
492
+
493
+ Closing either kind cancels a read that is still in flight. The reader marks itself closed, cancels the underlying channel operation, and settles the pending pull with its end-of-stream answer — `readByte()` returns `-1`, `read(sentinel)` returns the sentinel — so a consumer parked on a pull is released rather than left waiting for a channel that will never answer.
494
+
495
+ `close()` is idempotent. The first caller performs the work; every later caller awaits the same memoized outcome, and the channel is closed at most once.
496
+
497
+ All three facts are observable rather than asserted. The example below is a runnable file in the JVM-only `streams-examples` module of the [zio-blocks repository](https://github.com/zio/zio-blocks). It drives a scripted `AsynchronousByteChannel` — one that counts its own `close()` calls and parks a read it cannot serve — through both ownership modes, so the managed close, the untouched unmanaged channel, the memoized second close, and the cancelled pending read are all printed:
498
+
499
+ ```scala title="streams-examples/src/main/scala/nio/AsyncChannelReaderExample.scala"
500
+ package nio
501
+
502
+ import zio.blocks.async._
503
+ import zio.blocks.chunk.Chunk
504
+ import zio.blocks.streams.AsyncNioReaders
505
+
506
+ import java.nio.ByteBuffer
507
+ import java.nio.channels.{AsynchronousByteChannel, AsynchronousCloseException, CompletionHandler}
508
+ import java.nio.charset.StandardCharsets.UTF_8
509
+ import java.util.concurrent.atomic.AtomicInteger
510
+ import java.util.concurrent.{CompletableFuture, CountDownLatch, Future}
511
+
512
+ /**
513
+ * Managed and unmanaged readers over an `AsynchronousByteChannel` (JVM only).
514
+ *
515
+ * `AsyncNioReaders.fromChannel` takes ownership of the channel and closes it
516
+ * when the reader closes. `AsyncNioReaders.fromChannelUnmanaged` releases the
517
+ * reader and leaves the channel alive for its owner. Both memoize `close()`,
518
+ * and both settle a read that is still in flight when the reader closes.
519
+ *
520
+ * The channel below is scripted rather than networked: it counts its own
521
+ * `close()` calls and parks a read that has nothing left to deliver, so
522
+ * ownership, idempotent close, and the cancelled pending read are all
523
+ * observable without a socket.
524
+ */
525
+ object AsyncChannelReaderExample {
526
+ def main(args: Array[String]): Unit = {
527
+ managedClosesTheChannel()
528
+ unmanagedLeavesTheChannelOpen()
529
+ closeCancelsAPendingRead()
530
+ }
531
+
532
+ /** Managed ownership: the reader closes the channel, and only once. */
533
+ private def managedClosesTheChannel(): Unit = {
534
+ val channel = new ScriptedChannel("async".getBytes(UTF_8))
535
+ val reader = AsyncNioReaders.fromChannel(channel, bufferSize = 16)
536
+
537
+ val bytes: Chunk[Byte] = reader.readN[Byte](5).block
538
+ reader.close().block
539
+ reader.close().block // memoized: the channel is not closed a second time
540
+
541
+ println(
542
+ s"managed -> read=${text(bytes)}, channelOpen=${channel.isOpen}, closes=${channel.closeCount.get()}"
543
+ )
544
+ }
545
+
546
+ /** Unmanaged ownership: the channel outlives the reader untouched. */
547
+ private def unmanagedLeavesTheChannelOpen(): Unit = {
548
+ val channel = new ScriptedChannel("async".getBytes(UTF_8))
549
+ val reader = AsyncNioReaders.fromChannelUnmanaged(channel, bufferSize = 16)
550
+
551
+ val bytes: Chunk[Byte] = reader.readN[Byte](5).block
552
+ reader.close().block
553
+ reader.close().block
554
+
555
+ println(
556
+ s"unmanaged -> read=${text(bytes)}, channelOpen=${channel.isOpen}, closes=${channel.closeCount.get()}"
557
+ )
558
+ }
559
+
560
+ /**
561
+ * Closing an unmanaged reader cancels the read the channel is still holding
562
+ * and hands the consumer the end-of-stream answer instead.
563
+ */
564
+ private def closeCancelsAPendingRead(): Unit = {
565
+ val channel = new ScriptedChannel("hi".getBytes(UTF_8))
566
+ val reader = AsyncNioReaders.fromChannelUnmanaged(channel, bufferSize = 16)
567
+
568
+ val delivered: Chunk[Byte] = reader.readN[Byte](2).block
569
+ val pending = reader.readByte().start
570
+
571
+ // Wait until the channel is genuinely holding a read, so the close races an
572
+ // in-flight operation rather than an unstarted one.
573
+ channel.readParked.await()
574
+ reader.close().block
575
+
576
+ println(
577
+ s"pending -> read=${text(delivered)}, cancelledRead=${pending.block}, " +
578
+ s"channelOpen=${channel.isOpen}, closes=${channel.closeCount.get()}"
579
+ )
580
+ }
581
+
582
+ private def text(bytes: Chunk[Byte]): String = new String(bytes.toArray, UTF_8)
583
+
584
+ /**
585
+ * A channel that delivers `payload` once and then parks every further read
586
+ * until it is closed, the way a live socket waits between packets.
587
+ */
588
+ private final class ScriptedChannel(payload: Array[Byte]) extends AsynchronousByteChannel {
589
+ val closeCount: AtomicInteger = new AtomicInteger
590
+ val readParked: CountDownLatch = new CountDownLatch(1)
591
+
592
+ private val lock = new AnyRef
593
+ private var position = 0
594
+ private var open = true
595
+ private var parked: Throwable => Unit = null
596
+
597
+ def read[A](dst: ByteBuffer, attachment: A, handler: CompletionHandler[Integer, ? >: A]): Unit = {
598
+ val served = lock.synchronized {
599
+ val outcome = serve(dst)
600
+ if (outcome.isEmpty) parked = cause => handler.failed(cause, attachment)
601
+ outcome
602
+ }
603
+ served match {
604
+ case Some(count) => handler.completed(count, attachment)
605
+ case None => readParked.countDown()
606
+ }
607
+ }
608
+
609
+ def read(dst: ByteBuffer): Future[Integer] = {
610
+ val future = new CompletableFuture[Integer]
611
+ val served = lock.synchronized {
612
+ val outcome = serve(dst)
613
+ if (outcome.isEmpty) parked = cause => { future.completeExceptionally(cause); () }
614
+ outcome
615
+ }
616
+ served match {
617
+ case Some(count) => future.complete(count)
618
+ case None => readParked.countDown()
619
+ }
620
+ future
621
+ }
622
+
623
+ def write[A](src: ByteBuffer, attachment: A, handler: CompletionHandler[Integer, ? >: A]): Unit =
624
+ handler.failed(new UnsupportedOperationException("scripted channel is read-only"), attachment)
625
+
626
+ def write(src: ByteBuffer): Future[Integer] = {
627
+ val future = new CompletableFuture[Integer]
628
+ future.completeExceptionally(new UnsupportedOperationException("scripted channel is read-only"))
629
+ future
630
+ }
631
+
632
+ def isOpen: Boolean = lock.synchronized(open)
633
+
634
+ def close(): Unit = {
635
+ val release = lock.synchronized {
636
+ closeCount.incrementAndGet()
637
+ open = false
638
+ val current = parked
639
+ parked = null
640
+ current
641
+ }
642
+ if (release ne null) release(new AsynchronousCloseException)
643
+ }
644
+
645
+ private def serve(dst: ByteBuffer): Option[Int] = {
646
+ val remaining = payload.length - position
647
+ if (remaining <= 0) None
648
+ else {
649
+ val count = math.min(remaining, dst.remaining)
650
+ dst.put(payload, position, count)
651
+ position += count
652
+ Some(count)
653
+ }
654
+ }
655
+ }
656
+ }
657
+ ```
658
+
659
+ ([source](https://github.com/zio/zio-blocks/blob/main/streams-examples/src/main/scala/nio/AsyncChannelReaderExample.scala))
660
+
661
+ Run it with:
662
+
663
+ ```bash
664
+ sbt "streams-examples/runMain nio.AsyncChannelReaderExample"
665
+ ```
666
+
667
+ It prints:
668
+
669
+ ```
670
+ managed -> read=async, channelOpen=false, closes=1
671
+ unmanaged -> read=async, channelOpen=true, closes=0
672
+ pending -> read=hi, cancelledRead=-1, channelOpen=true, closes=0
673
+ ```
674
+
675
+ #### JVM Invariants
676
+
677
+ Four properties hold for every reader the `AsyncNioReaders` factories produce, and each one is a rule a hand-written channel wrapper commonly gets wrong.
678
+
679
+ 1. **A zero-byte completion is not end of stream.** When the channel completes a read having transferred nothing, the adapter clears its buffer and resubmits the read; only a negative completion count ends the stream. A channel that yields `0` under backpressure therefore stalls the pull, it does not truncate the stream.
680
+ 2. **`IOException`s are trusted source failures.** A failure reported by the channel is wrapped as a source failure and surfaces in the typed error channel of the stream built from the reader, not as a defect, and every later pull replays it rather than pretending the source recovered.
681
+ 3. **Pulls are inert until driven.** Every read method returns a deferred `Async`; building `reader.readByte()` submits nothing to the channel, and the read is issued when the effect is driven. `readable()` follows the same rule — it reports whether bytes are already buffered and never initiates I/O to find out.
682
+ 4. **One operation may be in flight at a time.** A second pull started while another is active fails with `IllegalStateException`; [One Active Operation at a Time](#one-active-operation-at-a-time) covers the rule and the way to sequence pulls instead.
683
+
684
+ #### JVM Limitations
685
+
686
+ The asynchronous NIO surface is exactly the four factories above, and file input is not among them.
687
+
688
+ :::warning[`AsynchronousFileChannel` is not supported]
689
+ No factory accepts an `AsynchronousFileChannel`, and it is not an `AsynchronousByteChannel`, so it cannot be passed to `AsyncNioReaders.fromChannel` either. There is no asynchronous file reader in this module.
690
+ :::
691
+
692
+ File bytes go through the synchronous `NioReaders.fromChannel` instead, as [From I/O](#from-io) describes.
693
+
694
+ #### Scala.js: `ReadableStreamReaders`
695
+
696
+ `ReadableStreamReaders` is the Scala.js counterpart, wrapping the byte-reading side of the Web Streams API. The module declares minimal `@js.native` facades for `ReadableStream`, its reader, and a read result, so using it does not pull a DOM library into your build.
697
+
698
+ Two factories mirror the managed and unmanaged pair on the JVM:
699
+
700
+ ```scala
701
+ object ReadableStreamReaders {
702
+ def fromReadableStream(stream: ReadableStream): Reader.AsyncReader[Byte]
703
+ def fromReadableStreamUnmanaged(stream: ReadableStream): Reader.AsyncReader[Byte]
704
+ }
705
+ ```
706
+
707
+ Both take the *stream*, not a reader. Each factory calls `stream.getReader()` itself and keeps the acquired reader for its own lifetime, which is what makes the lock release on close well defined. Acquiring a reader yourself and passing it in is not part of the API.
708
+
709
+ The managed factory owns the acquired reader: closing it cancels the JavaScript stream, awaits the read that was in flight, and then releases the lock. The unmanaged factory releases the lock and never cancels, so the underlying stream remains usable by the code that created it. As on the JVM, either kind settles a pending pull with end of stream instead of leaving it outstanding, and drops whatever it had buffered.
710
+
711
+ #### Scala.js Invariants
712
+
713
+ The JVM adapter's rules about deferral and exclusivity hold here too — pulls are inert until driven, and one operation may be in flight at a time. The four properties below restate the adapter's end-of-stream, buffering and failure behaviour in the terms of the Web Streams `read()` promise and its `done`/`value` result, which is where this adapter is easiest to get wrong.
714
+
715
+ 1. **An empty chunk is skipped, never treated as end of stream.** A result with `done = false` and a zero-length value causes the adapter to reissue `read()`; only `done = true` ends the stream.
716
+ 2. **Buffered bytes are preserved across pulls.** A chunk delivered by the stream is consumed byte by byte from the adapter's own index, so a pull that the buffer can satisfy issues no `read()` at all, and a partially consumed chunk survives until it is drained.
717
+ 3. **End of stream is observed without prefetch.** The adapter never calls `read()` merely to discover whether the stream has finished; it learns that from the read that a pull actually needed.
718
+ 4. **A rejected promise is a trusted source failure.** The rejection is wrapped as a source failure, surfaces in the typed error channel, and is replayed by every later pull.
719
+
720
+ #### Scala.js Limitations
721
+
722
+ Two of the JVM adapter's affordances have no Scala.js equivalent, and one of them cannot be worked around from user code.
723
+
724
+ :::warning[Byte-only, no BYOB, no buffer size]
725
+ Both factories produce `Reader.AsyncReader[Byte]` on the `JvmType.Byte` lane and read `Uint8Array` chunks; there is no factory for another element type. Neither factory offers BYOB support — `getReader()` is called with no arguments, so the adapter never acquires a bring-your-own-buffer reader and cannot read into a caller-supplied `ArrayBuffer`. Neither takes a buffer-size parameter: chunk sizes are whatever the underlying stream produces.
726
+ :::
727
+
728
+ ### Single Element
729
+
730
+ `Reader.single` — Creates a reader that emits exactly one element, then closes. Primitive types use specialized variants for zero-boxing:
731
+
732
+ ```scala
733
+ object Reader {
734
+ def single[A](value: A)(implicit jt: JvmType.Infer[A]): Reader.SyncReader[A]
735
+ def singleInt(value: Int): Reader.SyncReader[Int]
736
+ def singleLong(value: Long): Reader.SyncReader[Long]
737
+ def singleFloat(value: Float): Reader.SyncReader[Float]
738
+ def singleDouble(value: Double): Reader.SyncReader[Double]
739
+ def singleChar(value: Char): Reader.SyncReader[Char]
740
+ def singleShort(value: Short): Reader.SyncReader[Short]
741
+ def singleByte(value: Byte): Reader.SyncReader[Byte]
742
+ def singleBoolean(value: Boolean): Reader.SyncReader[Boolean]
743
+ }
744
+ ```
745
+
746
+ When you use `Reader.single`, behavior differs between reference types and primitives. The `JvmType.Infer[A]` implicit parameter enables compile-time type detection, automatically selecting the appropriate implementation (specialized primitive or reference-type generic).
747
+
748
+ For reference types like String, `Reader.single("hello")` stores the element directly and tracks a taken/not-taken flag internally. You read via the generic `SyncReader#read[A](sentinel)` method, passing your own sentinel value. On the first call, you get your string; on subsequent calls, you receive the sentinel you provided, allowing you to detect stream closure.
749
+
750
+ For primitive types, `Reader.single(42)` could naively box the integer, but the library avoids this penalty entirely via `SingletonPrim`—a zero-boxing specialization that stores the primitive unboxed in memory. The `JvmType.Infer` implicit detects this at compile time and routes you through specialized factory methods (`Reader.singleInt`, `Reader.singleLong`, etc.) and specialized read methods (`Reader#readInt`, `Reader#readLong`, etc.). Both storage and retrieval stay unboxed, maintaining zero-copy efficiency.
751
+
752
+ `Reader.singleByte` returns `SyncReader[Byte]` and reports `JvmType.Byte`. Its physical scalar pull is `readByte(): Int`, which returns the unsigned byte value `0`–`255` or `-1` at EOF.
753
+
754
+ Create and read from a single-element reference-type reader with a custom sentinel:
755
+
756
+ ```scala
757
+ import zio.blocks.streams.io.Reader
758
+
759
+ val r = Reader.single("hello")
760
+ // r: SyncReader[String] = zio.blocks.streams.io.Reader$SingletonGeneric@2d6a8112
761
+ val sentinel = "END"
762
+ // sentinel: String = "END"
763
+ println(r.read(sentinel)) // hello
764
+ // hello
765
+ println(r.read(sentinel)) // END (sentinel, reader is closed)
766
+ // END
767
+ ```
768
+
769
+ For primitive types, use the specialized factory and read methods. `SyncReader#readInt` takes a `Long` sentinel and returns `Long`; `AsyncReader#readInt` takes the same sentinel and returns `Async[Long]`:
770
+
771
+ ```scala
772
+ import zio.blocks.streams.io.Reader
773
+
774
+ val r = Reader.singleInt(100)
775
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$SingletonPrim@7e37e60c
776
+ val sentinel = Long.MinValue
777
+ // sentinel: Long = -9223372036854775808L
778
+ val v1 = r.readInt(sentinel)
779
+ // v1: Long = 100L
780
+ println(v1) // 100
781
+ // 100
782
+ val v2 = r.readInt(sentinel)
783
+ // v2: Long = -9223372036854775808L
784
+ println(v2) // -9223372036854775808 (sentinel, reader is closed)
785
+ // -9223372036854775808
786
+ ```
787
+
788
+ ### Infinite & Repeating
789
+
790
+ `Reader.repeat` — Creates an infinite reader that always emits the same value:
791
+
792
+ ```scala
793
+ object Reader {
794
+ def repeat[A](a: A)(implicit jt: JvmType.Infer[A]): Reader.SyncReader[A]
795
+ }
796
+ ```
797
+
798
+ Create an infinite reader that repeatedly emits the same value:
799
+
800
+ ```scala
801
+ import zio.blocks.streams.io.Reader
802
+
803
+ val r = Reader.repeat(1)
804
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$SingletonPrim@60b1fa62
805
+
806
+ def drainN(n: Int): Unit = {
807
+ if (n > 0) {
808
+ val v = r.read(-1)
809
+ println(v)
810
+ drainN(n - 1)
811
+ }
812
+ }
813
+ drainN(3)
814
+ // 1
815
+ // 1
816
+ // 1
817
+ // Output: 1, 1, 1
818
+ ```
819
+
820
+ `Reader.repeated` — Restarts an inner reader each time it closes cleanly. Used by `Stream.repeated` to create indefinitely repeating streams:
821
+
822
+ ```scala
823
+ object Reader {
824
+ def repeated[A](inner: SyncReader[A]): SyncReader[A]
825
+ def repeated[A](inner: AsyncReader[A]): AsyncReader[A]
826
+ def repeated[A](inner: Reader[A]): Reader[A]
827
+ }
828
+ ```
829
+
830
+ ### Unfold (State Machine)
831
+
832
+ `Reader.unfold` — Creates a reader by unfolding state with a function. Returns `None` to signal completion, or `Some((elem, nextState))` to emit an element and advance state:
833
+
834
+ ```scala
835
+ object Reader {
836
+ def unfold[S, A](s: S)(f: S => Option[(A, S)])(implicit jt: JvmType.Infer[A]): SyncReader[A]
837
+ }
838
+ ```
839
+
840
+ Create a reader that unfolds state incrementally until completion:
841
+
842
+ ```scala
843
+ import zio.blocks.streams.io.Reader
844
+
845
+ val r = Reader.unfold(1) { s =>
846
+ if (s > 3) None else Some((s, s + 1))
847
+ }
848
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$Unfold@57b73038
849
+
850
+ def drain(): Unit = {
851
+ val v = r.read(-1)
852
+ if (v != -1) {
853
+ println(v)
854
+ drain()
855
+ }
856
+ }
857
+ drain()
858
+ // 1
859
+ // 2
860
+ // 3
861
+ // Output: 1, 2, 3
862
+ ```
863
+
864
+ ### `Reader.unfoldAsync`
865
+
866
+ `Reader.unfoldAsync` is the only native asynchronous constructor in the companion. It has the same shape as `unfold`, with the step function returning its `Option` inside an `Async`, and it produces an `AsyncReader`:
867
+
868
+ ```scala
869
+ object Reader {
870
+ def unfoldAsync[S, A](s: S)(f: S => Async[Option[(A, S)]])(implicit jt: JvmType.Infer[A]): AsyncReader[A]
871
+ }
872
+ ```
873
+
874
+ Use it when producing the next element is itself asynchronous — a network round trip, a callback-based API, a timer. Everything downstream of it compiles on the asynchronous path.
875
+
876
+ ```scala
877
+ import zio.blocks.streams.io.Reader
878
+ import zio.blocks.chunk.Chunk
879
+ import zio.blocks.async._
880
+
881
+ val ticks: Reader.AsyncReader[Int] =
882
+ Reader.unfoldAsync(1) { s =>
883
+ Async.succeed(if (s > 3) None else Some((s, s + 1)))
884
+ }
885
+
886
+ val drained: Async[Chunk[Int]] = ticks.readAll()
887
+ val closed: Async[Unit] = ticks.close()
888
+ ```
889
+
890
+ The state callback is lazy and generation-aware: exactly one callback may be in flight, and the next state is committed only when that callback succeeds while its reader generation is still current. A callback that completes after a `reset` or a `close` therefore cannot advance state that no longer exists.
891
+
892
+ ## Core Operations
893
+
894
+ These methods form the primary interface for consuming elements and querying reader state:
895
+
896
+ ### Pulling Elements
897
+
898
+ `read` pulls the next element, or produces `sentinel` if the reader is closed and empty. This is the fundamental operation. The synchronous and asynchronous signatures are distinct:
899
+
900
+ ```scala
901
+ abstract class Reader.SyncReader[+Elem] {
902
+ def read[A >: Elem](sentinel: A): A
903
+ }
904
+
905
+ abstract class Reader.AsyncReader[+Elem] {
906
+ def read[A >: Elem](sentinel: A): Async[A]
907
+ }
908
+ ```
909
+
910
+ The sentinel value is caller-chosen and should never appear as a real element. For reference types, `null` is convenient. For primitives, use a value outside the domain (e.g., `-1` for unsigned bytes, `Long.MinValue` for `Int`):
911
+
912
+ ```scala
913
+ import zio.blocks.streams.io.Reader
914
+ import zio.blocks.chunk.Chunk
915
+
916
+ val r = Reader.fromChunk(Chunk(10, 20))
917
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@39ee3e03
918
+ val v1 = r.read(-1) // 10
919
+ // v1: Int = 10
920
+ val v2 = r.read(-1) // 20
921
+ // v2: Int = 20
922
+ val v3 = r.read(-1) // -1 (sentinel, reader is closed)
923
+ // v3: Int = -1
924
+ ```
925
+
926
+ ### Primitive Specialization
927
+
928
+ For primitive types, specialized methods avoid boxing by widening the return type.
929
+
930
+ `Reader#readInt` — Sentinel-return `Int` pull. Returns the element widened to `Long`, or `sentinel` when closed. The sentinel must lie outside `[Int.MinValue, Int.MaxValue]` (typically `Long.MinValue`):
931
+
932
+ ```scala
933
+ abstract class Reader.SyncReader[+Elem] {
934
+ def readInt(_sentinel: Long)(implicit _ev: Elem <:< Int): Long
935
+ }
936
+
937
+ abstract class Reader.AsyncReader[+Elem] {
938
+ def readInt(_sentinel: Long)(implicit _ev: Elem <:< Int): Async[Long]
939
+ }
940
+ ```
941
+
942
+ Why widen to `Long`? If `Reader#readInt` returned `Int`, you couldn't distinguish a real element from the sentinel—both would fit in the int range. By widening to `Long`, the sentinel (e.g., `Long.MinValue`) lies outside the possible int domain, allowing reliable end-of-stream detection. Cast the result back to `Int` if needed: `r.readInt(Long.MinValue).toInt`.
943
+
944
+ `Reader#readLong` — Sentinel-return `Long` pull. This low-level scalar method cannot distinguish EOF from a real element equal to the caller's sentinel:
945
+
946
+ ```scala
947
+ abstract class Reader.SyncReader[+Elem] {
948
+ def readLong(_sentinel: Long)(implicit _ev: Elem <:< Long): Long
949
+ }
950
+
951
+ abstract class Reader.AsyncReader[+Elem] {
952
+ def readLong(_sentinel: Long)(implicit _ev: Elem <:< Long): Async[Long]
953
+ }
954
+ ```
955
+
956
+ The scalar API necessarily permits a collision with the caller's sentinel. Collision-free internal pulls preserve the complete `Long` domain by calling `readLongs` with a length-one array and using its returned count (`-1` for EOF, `1` for data) as status. Custom full-domain loops should use the same pattern.
957
+
958
+ `Reader#readFloat` — Sentinel-return `Float` pull. Returns the element widened to `Double`, or `sentinel` when closed:
959
+
960
+ ```scala
961
+ abstract class Reader.SyncReader[+Elem] {
962
+ def readFloat(_sentinel: Double)(implicit _ev: Elem <:< Float): Double
963
+ }
964
+
965
+ abstract class Reader.AsyncReader[+Elem] {
966
+ def readFloat(_sentinel: Double)(implicit _ev: Elem <:< Float): Async[Double]
967
+ }
968
+ ```
969
+
970
+ Like `Reader#readInt`, widening to `Double` allows the sentinel to lie safely outside the float domain. A float value will always fit in the lower precision bits of the double result, and the sentinel (typically `Double.MaxValue`) occupies the upper range. This ensures you can reliably distinguish real float elements from end-of-stream. Cast back to `Float` if needed: `r.readFloat(Double.MaxValue).toFloat`.
971
+
972
+ `Reader#readDouble` — Sentinel-return `Double` pull. Returns the element, or `sentinel` when closed. No `Double` bit pattern can be reserved as a collision-free sentinel, so the parameter is kept for symmetry with the other sentinel-taking pulls and for callers who can prove that a particular value lies outside their own data domain:
973
+
974
+ ```scala
975
+ abstract class Reader.SyncReader[+Elem] {
976
+ def readDouble(_sentinel: Double)(implicit _ev: Elem <:< Double): Double
977
+ }
978
+
979
+ abstract class Reader.AsyncReader[+Elem] {
980
+ def readDouble(_sentinel: Double)(implicit _ev: Elem <:< Double): Async[Double]
981
+ }
982
+ ```
983
+
984
+ Like scalar `readLong`, scalar `readDouble` cannot reserve a collision-free value (and NaN comparisons add another trap). Collision-free internal pulls call `readDoubles` with a length-one array and use its returned count as EOF/data status, preserving infinities, every NaN payload, and either zero. Custom full-domain loops should use the same pattern.
985
+
986
+ These specialized methods are the hot path for primitive streams — they avoid allocation and boxing entirely:
987
+
988
+ ```scala
989
+ import zio.blocks.streams.io.Reader
990
+ import zio.blocks.chunk.Chunk
991
+
992
+ val r = Reader.fromChunk(Chunk(10, 20, 30))
993
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@137fc020
994
+ val sentinel = Long.MinValue
995
+ // sentinel: Long = -9223372036854775808L
996
+
997
+ val v = r.readInt(sentinel)
998
+ // v: Long = 10L
999
+ ```
1000
+
1001
+ ### Byte-Level Reading
1002
+
1003
+ `Reader#readByte` — Reads a single byte (0–255), widened to `Int`. Returns `-1` when the reader is closed. Dispatches on `Reader#jvmType` for zero-boxing when the reader is specialized:
1004
+
1005
+ ```scala
1006
+ abstract class Reader.SyncReader[+Elem] {
1007
+ def readByte(): Int
1008
+ }
1009
+
1010
+ abstract class Reader.AsyncReader[+Elem] {
1011
+ def readByte(): Async[Int]
1012
+ }
1013
+ ```
1014
+
1015
+ Read bytes one at a time from a reader until end-of-stream:
1016
+
1017
+ ```scala
1018
+ import zio.blocks.streams.io.Reader
1019
+ import java.io.ByteArrayInputStream
1020
+
1021
+ val bytes = Array[Byte](72, 101, 108, 108, 111) // Hello in ASCII bytes
1022
+ // bytes: Array[Byte] = Array(72, 101, 108, 108, 111)
1023
+ val is = new ByteArrayInputStream(bytes)
1024
+ // is: ByteArrayInputStream = java.io.ByteArrayInputStream@640950b
1025
+ val r = Reader.fromInputStream(is)
1026
+ // r: SyncReader[Byte] = zio.blocks.streams.io.Reader$InputStreamReader@76c1d83a
1027
+
1028
+ def drainBytes(): Unit = {
1029
+ val b = r.readByte()
1030
+ if (b != -1) {
1031
+ println(s"Byte: $b (${b.toChar})")
1032
+ drainBytes()
1033
+ }
1034
+ }
1035
+ drainBytes()
1036
+ // Byte: 72 (H)
1037
+ // Byte: 101 (e)
1038
+ // Byte: 108 (l)
1039
+ // Byte: 108 (l)
1040
+ // Byte: 111 (o)
1041
+ // Output:
1042
+ // Byte: 72 (H)
1043
+ // Byte: 101 (e)
1044
+ // Byte: 108 (l)
1045
+ // Byte: 108 (l)
1046
+ // Byte: 111 (o)
1047
+ ```
1048
+
1049
+ `Reader#readBytes` — Bulk byte read into a caller-supplied buffer, mirroring `java.io.InputStream#read(byte[], int, int)`. The behavior is:
1050
+
1051
+ - A `SyncReader` blocks until at least 1 byte is available; an `AsyncReader` represents that wait in `Async`.
1052
+ - Returns the number of bytes read (`1 <= r <= len`).
1053
+ - Returns `-1` when closed and empty.
1054
+ - Returns `0` immediately when `len == 0`.
1055
+
1056
+ The method signature is:
1057
+
1058
+ ```scala
1059
+ abstract class Reader.SyncReader[+Elem] {
1060
+ def readBytes(buf: Array[Byte], offset: Int, len: Int)(implicit ev: Elem <:< Byte): Int
1061
+ }
1062
+
1063
+ abstract class Reader.AsyncReader[+Elem] {
1064
+ def readBytes(dest: Array[Byte], offset: Int, length: Int)(implicit ev: Elem <:< Byte): Async[Int]
1065
+ }
1066
+ ```
1067
+
1068
+ Read multiple bytes into a buffer in bulk with a loop pattern:
1069
+
1070
+ ```scala
1071
+ import zio.blocks.streams.io.Reader
1072
+ import java.io.ByteArrayInputStream
1073
+
1074
+ val bytes = Array[Byte](72, 101, 108, 108, 111) // The word Hello
1075
+ // bytes: Array[Byte] = Array(72, 101, 108, 108, 111)
1076
+ val is = new ByteArrayInputStream(bytes)
1077
+ // is: ByteArrayInputStream = java.io.ByteArrayInputStream@7b76b6d9
1078
+ val r = Reader.fromInputStream(is)
1079
+ // r: SyncReader[Byte] = zio.blocks.streams.io.Reader$InputStreamReader@43a74358
1080
+
1081
+ val buffer = new Array[Byte](3)
1082
+ // buffer: Array[Byte] = Array(108, 111, 108)
1083
+
1084
+ def drainBulk(): Unit = {
1085
+ val bytesRead = r.readBytes(buffer, 0, 3)
1086
+ if (bytesRead > 0) {
1087
+ val chunk = buffer.take(bytesRead).map(_.toChar).mkString
1088
+ println(s"Read $bytesRead bytes: $chunk")
1089
+ drainBulk()
1090
+ }
1091
+ }
1092
+ drainBulk()
1093
+ // Read 3 bytes: Hel
1094
+ // Read 2 bytes: lo
1095
+ // Output:
1096
+ // Read 3 bytes: Hel
1097
+ // Read 2 bytes: lo
1098
+ ```
1099
+
1100
+ ### Character and Numeric Specialization
1101
+
1102
+ `Reader#readChar` — Sentinel-return `Char` pull. Returns the element widened to `Int`, or `sentinel` when closed. Requires evidence that `Elem <:< Char`:
1103
+
1104
+ ```scala
1105
+ abstract class Reader.SyncReader[+Elem] {
1106
+ def readChar(_sentinel: Int)(implicit _ev: Elem <:< Char): Int
1107
+ }
1108
+
1109
+ abstract class Reader.AsyncReader[+Elem] {
1110
+ def readChar(_sentinel: Int)(implicit _ev: Elem <:< Char): Async[Int]
1111
+ }
1112
+ ```
1113
+
1114
+ `Reader#readShort` — Sentinel-return `Short` pull. Returns the element widened to `Int`, or `sentinel` when closed:
1115
+
1116
+ ```scala
1117
+ abstract class Reader.SyncReader[+Elem] {
1118
+ def readShort(_sentinel: Int)(implicit _ev: Elem <:< Short): Int
1119
+ }
1120
+
1121
+ abstract class Reader.AsyncReader[+Elem] {
1122
+ def readShort(_sentinel: Int)(implicit _ev: Elem <:< Short): Async[Int]
1123
+ }
1124
+ ```
1125
+
1126
+ `Reader#readBoolean` — Sentinel-return `Boolean` pull. Returns `1` for `true`, `0` for `false`, or `sentinel` when closed. The sentinel must lie outside `[0, 1]` (typically `-1`):
1127
+
1128
+ ```scala
1129
+ abstract class Reader.SyncReader[+Elem] {
1130
+ def readBoolean(_sentinel: Int)(implicit _ev: Elem <:< Boolean): Int
1131
+ }
1132
+
1133
+ abstract class Reader.AsyncReader[+Elem] {
1134
+ def readBoolean(_sentinel: Int)(implicit _ev: Elem <:< Boolean): Async[Int]
1135
+ }
1136
+ ```
1137
+
1138
+ ### Bulk Operations
1139
+
1140
+ `Reader#readAll` — Drains the entire reader into a `Chunk`. Dispatches on `Reader#jvmType` for zero-boxing on primitive readers:
1141
+
1142
+ ```scala
1143
+ abstract class Reader.SyncReader[+Elem] {
1144
+ def readAll[A >: Elem](): Chunk[A]
1145
+ }
1146
+
1147
+ abstract class Reader.AsyncReader[+Elem] {
1148
+ def readAll[A >: Elem](): Async[Chunk[A]]
1149
+ }
1150
+ ```
1151
+
1152
+ The result is a new chunk containing all remaining elements:
1153
+
1154
+ ```scala
1155
+ import zio.blocks.streams.io.Reader
1156
+ import zio.blocks.chunk.Chunk
1157
+
1158
+ val r = Reader.fromChunk(Chunk(10, 20, 30))
1159
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@27eaf89c
1160
+ val all = r.readAll()
1161
+ // all: Chunk[Int] = IndexedSeq(10, 20, 30)
1162
+ println(all) // Chunk(10,20,30)
1163
+ // Chunk(10,20,30)
1164
+ ```
1165
+
1166
+ `Reader#readN` and `Reader#readUpToN` — Bounded drains. `readN` gathers up to `n` elements, returning early only when the reader is exhausted; `readUpToN` gathers at most `n` elements and stops as soon as the next element is not already available, so it never waits for a slow producer to fill the request. Both produce an empty chunk when `n <= 0` or the reader is at end-of-stream:
1167
+
1168
+ ```scala
1169
+ abstract class Reader.SyncReader[+Elem] {
1170
+ def readN[A >: Elem](n: Int): Chunk[A]
1171
+ def readUpToN[A >: Elem](n: Int): Chunk[A]
1172
+ }
1173
+
1174
+ abstract class Reader.AsyncReader[+Elem] {
1175
+ def readN[A >: Elem](n: Int): Async[Chunk[A]]
1176
+ def readUpToN[A >: Elem](n: Int): Async[Chunk[A]]
1177
+ }
1178
+ ```
1179
+
1180
+ :::caution[Bound `n` yourself]
1181
+ `n` is a request, and some readers size a buffer from it before knowing how much data will arrive. The JVM channel-backed byte reader allocates `new Array[Byte](n)` up front in `readUpToN`, and the only guards on that allocation are `n <= 0` and an already-closed reader — there is no upper bound. Passing `Int.MaxValue` therefore asks for a 2 GB array rather than "whatever is ready". Choose a bound that reflects how much you are prepared to hold in memory, such as a page or buffer size.
1182
+ :::
1183
+
1184
+ `Reader#skip` — Eagerly discards the first `n` elements. Dispatches on `Reader#jvmType` for zero-boxing when possible:
1185
+
1186
+ ```scala
1187
+ abstract class Reader.SyncReader[+Elem] {
1188
+ def skip(n: Long): Unit
1189
+ }
1190
+
1191
+ abstract class Reader.AsyncReader[+Elem] {
1192
+ def skip(n: Long): Async[Unit]
1193
+ }
1194
+ ```
1195
+
1196
+ ### State Queries
1197
+
1198
+ `isClosed` reports whether the reader is closed. Its result is monotone: once `true`, it never becomes `false`:
1199
+
1200
+ ```scala
1201
+ abstract class Reader.SyncReader[+Elem] {
1202
+ def isClosed: Boolean
1203
+ }
1204
+
1205
+ abstract class Reader.AsyncReader[+Elem] {
1206
+ def isClosed: Async[Boolean]
1207
+ }
1208
+ ```
1209
+
1210
+ `readable` reports whether the next `read()` would produce a value (not the sentinel). On `AsyncReader` the answer itself is asynchronous. Buffered readers can override it for an accurate, non-consuming probe:
1211
+
1212
+ ```scala
1213
+ abstract class Reader.SyncReader[+Elem] {
1214
+ def readable(): Boolean
1215
+ }
1216
+
1217
+ abstract class Reader.AsyncReader[+Elem] {
1218
+ def readable(): Async[Boolean]
1219
+ }
1220
+ ```
1221
+
1222
+ Use `readable()` to check if elements are available before calling `read()`:
1223
+
1224
+ ```scala
1225
+ import zio.blocks.streams.io.Reader
1226
+ import zio.blocks.chunk.Chunk
1227
+
1228
+ val r = Reader.fromChunk(Chunk(1, 2))
1229
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@51d94e24
1230
+ println(r.readable()) // true
1231
+ // true
1232
+ r.read(-1)
1233
+ // res39: Int = 1
1234
+ println(r.readable()) // true
1235
+ // true
1236
+ r.read(-1)
1237
+ // res41: Int = 2
1238
+ println(r.readable()) // false
1239
+ // false
1240
+ ```
1241
+
1242
+ ## Asynchronous Reading
1243
+
1244
+ `Reader.AsyncReader[Elem]` is the kind a stream materializes as whenever its graph contains an asynchronous node. It is not a second API: it is the surface described above with every result moved inside `Async`. What follows is that member list, and the handful of behaviours that are specific to the asynchronous kind.
1245
+
1246
+ A custom asynchronous reader supplies four members. Everything else on the class has a working default built on top of them:
1247
+
1248
+ ```scala
1249
+ abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
1250
+ def read[A >: Elem](sentinel: A): Async[A]
1251
+ def readable(): Async[Boolean]
1252
+ def isClosed: Async[Boolean]
1253
+ def close(): Async[Unit]
1254
+ }
1255
+ ```
1256
+
1257
+ That is enough to build a reader the whole stream machinery can drive:
1258
+
1259
+ ```scala
1260
+ import zio.blocks.streams.io.Reader
1261
+ import zio.blocks.async._
1262
+
1263
+ final class OneShot(value: Int) extends Reader.AsyncReader[Int] {
1264
+ private var delivered = false
1265
+ private var closed = false
1266
+
1267
+ def read[A >: Int](sentinel: A): Async[A] =
1268
+ if (closed || delivered) Async.succeed(sentinel)
1269
+ else { delivered = true; Async.succeed(value) }
1270
+
1271
+ def readable(): Async[Boolean] = Async.succeed(!closed && !delivered)
1272
+ def isClosed: Async[Boolean] = Async.succeed(closed)
1273
+ def close(): Async[Unit] = Async.succeed { closed = true }
1274
+ }
1275
+ ```
1276
+
1277
+ The three bulk reads are derived from `read` and the reader's lane, so an implementation gets them for free and overrides them only to exploit a cheaper native path:
1278
+
1279
+ ```scala
1280
+ abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
1281
+ def readAll[A >: Elem](): Async[Chunk[A]]
1282
+ def readN[A >: Elem](n: Int): Async[Chunk[A]]
1283
+ def readUpToN[A >: Elem](n: Int): Async[Chunk[A]]
1284
+ }
1285
+ ```
1286
+
1287
+ `readAll()` is `readN(Int.MaxValue)`, `readN` gathers until it has `n` elements or hits end-of-stream, and `readUpToN` additionally stops as soon as the next element is not already available. All three yield to the scheduler after a fixed budget of consecutive pulls, so a fast in-memory reader cannot monopolize the calling thread.
1288
+
1289
+ The eight primitive pulls mirror the synchronous lane exactly, including the widened carriers — a `Char`, `Short`, `Boolean`, or `Byte` lane returns its value in an `Int`, an `Int` lane in a `Long`, and a `Float` lane in a `Double` — with the result inside `Async`:
1290
+
1291
+ ```scala
1292
+ abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
1293
+ def readBoolean(_sentinel: Int)(implicit _ev: Elem <:< Boolean): Async[Int]
1294
+ def readByte(): Async[Int]
1295
+ def readChar(_sentinel: Int)(implicit _ev: Elem <:< Char): Async[Int]
1296
+ def readShort(_sentinel: Int)(implicit _ev: Elem <:< Short): Async[Int]
1297
+ def readInt(_sentinel: Long)(implicit _ev: Elem <:< Int): Async[Long]
1298
+ def readLong(_sentinel: Long)(implicit _ev: Elem <:< Long): Async[Long]
1299
+ def readFloat(_sentinel: Double)(implicit _ev: Elem <:< Float): Async[Double]
1300
+ def readDouble(_sentinel: Double)(implicit _ev: Elem <:< Double): Async[Double]
1301
+ }
1302
+ ```
1303
+
1304
+ Calling one of these on a reader whose `jvmType` is a different primitive lane does not throw at the call site: it returns a failed `Async` carrying an `UnsupportedOperationException` that names both lanes. A reader on the `AnyRef` lane, by contrast, satisfies every one of them by pulling boxed and converting.
1305
+
1306
+ Five bulk array transfers fill a caller-supplied array and report how many elements were written, or `-1` when the reader was already at end-of-stream:
1307
+
1308
+ ```scala
1309
+ abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
1310
+ def readBytes(dest: Array[Byte], offset: Int, length: Int)(implicit ev: Elem <:< Byte): Async[Int]
1311
+ def readInts(dest: Array[Int], offset: Int, length: Int)(implicit ev: Elem <:< Int): Async[Int]
1312
+ def readLongs(dest: Array[Long], offset: Int, length: Int)(implicit ev: Elem <:< Long): Async[Int]
1313
+ def readFloats(dest: Array[Float], offset: Int, length: Int)(implicit ev: Elem <:< Float): Async[Int]
1314
+ def readDoubles(dest: Array[Double], offset: Int, length: Int)(implicit ev: Elem <:< Double): Async[Int]
1315
+ }
1316
+ ```
1317
+
1318
+ A transfer also stops short of `length` when the next element is not already available, so a partial count is a normal result rather than a sign of end-of-stream. An out-of-range `offset` or `length` surfaces as a failed `Async`, not a thrown exception.
1319
+
1320
+ The five control operations complete the mirror:
1321
+
1322
+ ```scala
1323
+ abstract class Reader.AsyncReader[+Elem] extends Reader[Elem] {
1324
+ def skip(n: Long): Async[Unit]
1325
+ def reset(): Async[Unit]
1326
+ def setLimit(n: Long): Async[Boolean]
1327
+ def setRepeat(): Async[Boolean]
1328
+ def setSkip(n: Long): Async[Boolean]
1329
+ }
1330
+ ```
1331
+
1332
+ `skip` has a real default that discards elements through the reader's own lane. The pushdown operations do not: on the base class `reset()` fails with an `UnsupportedOperationException`, and `setLimit`, `setRepeat`, and `setSkip` each succeed with `false`. Those defaults are the honest answer for a reader that cannot rewind or bound itself natively, and callers already handle them — a `false` simply means the interpreter wraps the reader instead of pushing the operation down. Override them only when your reader can genuinely do the work in O(1).
1333
+
1334
+ ### One Active Operation at a Time
1335
+
1336
+ An `AsyncReader` is a single-consumer cursor with one position and one lifecycle. **At most one operation may be in flight at a time.** Await the `Async` returned by a pull, a transfer, a control operation, or `close()` before beginning the next one.
1337
+
1338
+ For an implementor this is a contract you may rely on and must not weaken: your `read` will not be re-entered while a previous `read` is still pending, so internal position and buffer state need no defence against overlap. It is also a contract you inherit — a reader you wrap gets the same guarantee only if you preserve it, so never fan a single downstream pull out into concurrent pulls on your source.
1339
+
1340
+ Readers are not thread-safe either. Driving one reader from two threads without external synchronization is outside the contract, and the result is not specified. [Asynchronous Stream Execution](../execution-and-compatibility/async-execution.md#one-active-operation-per-reader) states the same rule from the consumer's side.
1341
+
1342
+ ### Close Ownership
1343
+
1344
+ Every asynchronous reader has exactly one owner, and the owner is responsible for awaiting `close()`. Which side holds it is never ambiguous, because the entry point that handed you the reader decides: terminals own and close the reader they compile, `Stream#startAsync` transfers ownership to you, and `Stream#useReaderAsync` retains it. [Manual Pull and Ownership](../execution-and-compatibility/async-execution.md#manual-pull-and-ownership) gives each case in full from the consumer's side, including what a forgotten `close()` costs. The rest of this section is what ownership means for the reader itself.
1345
+
1346
+ `close()` is itself an asynchronous operation: it participates in the one-active-operation rule, it cancels or joins work already in flight, and its result must be awaited rather than discarded. Library readers tolerate a repeated close, but the owner should still close exactly once.
1347
+
1348
+ For an implementor, `close()` is where release actions and underlying resources are surfaced. A failure during cleanup is reported through the returned `Async` rather than swallowed, so do not let a failing release leave the reader believing it is still open.
1349
+
1350
+ ```scala
1351
+ import zio.blocks.streams._
1352
+ import zio.blocks.streams.io.Reader
1353
+ import zio.blocks.chunk.Chunk
1354
+ import zio.blocks.async._
1355
+
1356
+ // Ownership retained by the library: the reader is closed on every outcome.
1357
+ val firstFive: Async[Chunk[Int]] =
1358
+ Stream.range(1, 100).useReaderAsync { (r: Reader.AsyncReader[Int]) =>
1359
+ r.readN(5)
1360
+ }
1361
+
1362
+ // Ownership transferred to the caller: closing is now your job.
1363
+ val owned: Async[Chunk[Int]] =
1364
+ Stream.range(1, 100).startAsync.flatMap { r =>
1365
+ r.readN(5).flatMap(chunk => r.close().map(_ => chunk))
1366
+ }
1367
+ ```
1368
+
1369
+ ## Composition
1370
+
1371
+ Combine multiple readers to build more complex sources:
1372
+
1373
+ ### Concatenation
1374
+
1375
+ `Reader#concat` — Concatenates this reader with `next`. When this reader is exhausted, it is closed and elements are pulled from `next` (evaluated lazily). Optimized for left-associative chains:
1376
+
1377
+ ```scala
1378
+ abstract class Reader[+Elem] {
1379
+ def concat[Elem2 >: Elem](next: () => Reader[Elem2]): Reader[Elem2]
1380
+ }
1381
+ ```
1382
+
1383
+ `Reader#++` — Alias for `Reader#concat`. Syntactic sugar for composing readers:
1384
+
1385
+ ```scala
1386
+ abstract class Reader[+Elem] {
1387
+ def ++[Elem2 >: Elem](next: => Reader[Elem2]): Reader[Elem2]
1388
+ }
1389
+ ```
1390
+
1391
+ Here is how concatenation chains multiple readers together:
1392
+
1393
+ ```scala
1394
+ import zio.blocks.streams.io.Reader
1395
+ import zio.blocks.chunk.Chunk
1396
+
1397
+ val r1 = Reader.fromChunk(Chunk(1, 2))
1398
+ // r1: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@71d55ad4
1399
+ val r2 = Reader.fromChunk(Chunk(3, 4))
1400
+ // r2: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@135b9e72
1401
+ val combined = r1 ++ r2
1402
+ // combined: SyncReader[Int] = zio.blocks.streams.io.Reader$ConcatReader@4d6fb94b
1403
+
1404
+ def drain(): Unit = {
1405
+ val v = combined.read(-1)
1406
+ if (v != -1) {
1407
+ println(v)
1408
+ drain()
1409
+ }
1410
+ }
1411
+ drain()
1412
+ // 1
1413
+ // 2
1414
+ // 3
1415
+ // 4
1416
+ // Output: 1, 2, 3, 4
1417
+ ```
1418
+
1419
+ These are the root's declarations, which answer with a `Reader[Elem2]`. `SyncReader` narrows them with a second pair of overloads so that concatenating two synchronous readers gives back a `SyncReader`; see [Mixed-kind composition](#mixed-kind-composition) for which combination produces which kind.
1420
+
1421
+ **Optimization**: If this reader is already a `ConcatReader`, the thunk is appended to its internal array and `this` is returned (mutable append, O(1) amortized). Otherwise a new `ConcatReader` is created. This ensures that left-associative chains like `a ++ b ++ c ++ d` compile into a single flat `ConcatReader` with O(1) per-element read, rather than O(n) nested wrappers.
1422
+
1423
+ ## Resource Management
1424
+
1425
+ Close readers and attach cleanup callbacks:
1426
+
1427
+ ### Closing
1428
+
1429
+ `close` signals end-of-stream from the consumer side and releases any held resources. Implementations set internal closed state and wake or cancel any pending work. A synchronous owner calls it directly; an asynchronous owner must run and await the returned `Async`:
1430
+
1431
+ ```scala
1432
+ abstract class Reader.SyncReader[+Elem] {
1433
+ def close(): Unit
1434
+ }
1435
+
1436
+ abstract class Reader.AsyncReader[+Elem] {
1437
+ def close(): Async[Unit]
1438
+ }
1439
+ ```
1440
+
1441
+ `SyncReader#withRelease` wraps a synchronous reader so that `release` runs when it closes. `withReleaseAsync`, available on the root and therefore on both kinds, returns an `AsyncReader` and awaits asynchronous cleanup:
1442
+
1443
+ ```scala
1444
+ abstract class Reader.SyncReader[+Elem] {
1445
+ def withRelease(release: () => Unit): Reader.SyncReader[Elem]
1446
+ }
1447
+
1448
+ abstract class Reader[+Elem] {
1449
+ def withReleaseAsync(release: () => Async[Unit]): Reader.AsyncReader[Elem]
1450
+ }
1451
+ ```
1452
+
1453
+ Here is how cleanup logic is attached to a reader:
1454
+
1455
+ ```scala
1456
+ import zio.blocks.streams.io.Reader
1457
+ import zio.blocks.chunk.Chunk
1458
+ import scala.sys.Prop
1459
+
1460
+ val cleanupRef = scala.collection.mutable.ListBuffer[String]()
1461
+ // cleanupRef: ListBuffer[String] = ListBuffer("cleaned")
1462
+ val r = Reader.fromChunk(Chunk(1, 2)).withRelease { () =>
1463
+ cleanupRef += "cleaned"
1464
+ println("Cleaned up")
1465
+ }
1466
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$SyncReader$$anon$3@219c510b
1467
+
1468
+ r.close()
1469
+ // Cleaned up
1470
+ println(cleanupRef.nonEmpty) // true
1471
+ // true
1472
+ ```
1473
+
1474
+ ## Pushdown Operations
1475
+
1476
+ Readers can sometimes handle skip, limit, and repeat operations natively (O(1), zero per-element cost). These methods attempt that; if the reader cannot handle it natively, they return `false` and the caller must wrap the reader.
1477
+
1478
+ `Reader#setSkip` — Attempts to set a skip (drop) on this reader. Returns `true` if handled natively, `false` if the caller must wrap. When `true`, the next n elements are discarded before producing. After `Reader#reset()`, the skip is re-applied:
1479
+
1480
+ ```scala
1481
+ abstract class Reader.SyncReader[+Elem] {
1482
+ def setSkip(n: Long): Boolean
1483
+ }
1484
+
1485
+ abstract class Reader.AsyncReader[+Elem] {
1486
+ def setSkip(n: Long): Async[Boolean]
1487
+ }
1488
+ ```
1489
+
1490
+ Set a skip to discard the first two elements:
1491
+
1492
+ ```scala
1493
+ import zio.blocks.streams.io.Reader
1494
+ import zio.blocks.chunk.Chunk
1495
+
1496
+ val r = Reader.fromChunk(Chunk(1, 2, 3, 4, 5))
1497
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@3a51e8e2
1498
+ val handled = r.setSkip(2)
1499
+ // handled: Boolean = true
1500
+ println(s"Skip handled natively: $handled")
1501
+ // Skip handled natively: true
1502
+
1503
+ def drain(): Unit = {
1504
+ val v = r.read(-1)
1505
+ if (v != -1) {
1506
+ println(v)
1507
+ drain()
1508
+ }
1509
+ }
1510
+ drain()
1511
+ // 3
1512
+ // 4
1513
+ // 5
1514
+ // Output:
1515
+ // Skip handled natively: true
1516
+ // 3
1517
+ // 4
1518
+ // 5
1519
+ ```
1520
+
1521
+ `Reader#setLimit` — Attempts to set a limit on this reader so it produces at most `n` elements. Returns `true` if handled natively, `false` if the caller must wrap. After `reset()`, the limit is re-applied from the new start position:
1522
+
1523
+ ```scala
1524
+ abstract class Reader.SyncReader[+Elem] {
1525
+ def setLimit(n: Long): Boolean
1526
+ }
1527
+
1528
+ abstract class Reader.AsyncReader[+Elem] {
1529
+ def setLimit(n: Long): Async[Boolean]
1530
+ }
1531
+ ```
1532
+
1533
+ Set a limit to produce only three elements:
1534
+
1535
+ ```scala
1536
+ import zio.blocks.streams.io.Reader
1537
+ import zio.blocks.chunk.Chunk
1538
+
1539
+ val r = Reader.fromChunk(Chunk(1, 2, 3, 4, 5))
1540
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@39b592b4
1541
+ val handled = r.setLimit(3)
1542
+ // handled: Boolean = true
1543
+ println(s"Limit handled natively: $handled")
1544
+ // Limit handled natively: true
1545
+
1546
+ def drain(): Unit = {
1547
+ val v = r.read(-1)
1548
+ if (v != -1) {
1549
+ println(v)
1550
+ drain()
1551
+ }
1552
+ }
1553
+ drain()
1554
+ // 1
1555
+ // 2
1556
+ // 3
1557
+ // Output:
1558
+ // Limit handled natively: true
1559
+ // 1
1560
+ // 2
1561
+ // 3
1562
+ ```
1563
+
1564
+ `Reader#setRepeat` — Attempts to set this reader into repeat-forever mode, so it restarts from the beginning whenever it would otherwise close. Returns `true` if handled natively, `false` if the caller must wrap:
1565
+
1566
+ ```scala
1567
+ abstract class Reader.SyncReader[+Elem] {
1568
+ def setRepeat(): Boolean
1569
+ }
1570
+
1571
+ abstract class Reader.AsyncReader[+Elem] {
1572
+ def setRepeat(): Async[Boolean]
1573
+ }
1574
+ ```
1575
+
1576
+ Set repeat mode so a reader restarts instead of closing. Not every reader can do this natively — the chunk-, iterable- and range-backed readers cannot, and return `false`; the single-element readers can:
1577
+
1578
+ ```scala
1579
+ import zio.blocks.streams.io.Reader
1580
+
1581
+ val r = Reader.single(1)
1582
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$SingletonPrim@1157068a
1583
+ val handled = r.setRepeat()
1584
+ // handled: Boolean = true
1585
+ println(s"Repeat handled natively: $handled")
1586
+ // Repeat handled natively: true
1587
+
1588
+ def drain(count: Int): Unit = {
1589
+ if (count < 4) {
1590
+ val v = r.read(-1)
1591
+ println(v)
1592
+ drain(count + 1)
1593
+ }
1594
+ }
1595
+ drain(0)
1596
+ // 1
1597
+ // 1
1598
+ // 1
1599
+ // 1
1600
+ // Output:
1601
+ // Repeat handled natively: true
1602
+ // 1
1603
+ // 1
1604
+ // 1
1605
+ // 1
1606
+ ```
1607
+
1608
+ `Reader.fromChunk(Chunk(1, 2)).setRepeat()` returns `false` instead: chunk-backed readers do not implement repeat, so the caller wraps the reader rather than pushing the operation down.
1609
+
1610
+ `Reader#reset` — Rewinds this reader to its initial state, as if freshly constructed. After `Reader#reset()`, all elements are available again from the beginning. Not all readers support this; readers backed by one-shot resources (InputStreams, `java.io.Reader`s) throw `UnsupportedOperationException`:
1611
+
1612
+ ```scala
1613
+ abstract class Reader.SyncReader[+Elem] {
1614
+ def reset(): Unit
1615
+ }
1616
+
1617
+ abstract class Reader.AsyncReader[+Elem] {
1618
+ def reset(): Async[Unit]
1619
+ }
1620
+ ```
1621
+
1622
+ After rewinding, the reader starts from the beginning:
1623
+
1624
+ ```scala
1625
+ import zio.blocks.streams.io.Reader
1626
+ import zio.blocks.chunk.Chunk
1627
+
1628
+ val r = Reader.fromChunk(Chunk(1, 2, 3))
1629
+ // r: SyncReader[Int] = zio.blocks.streams.io.Reader$FromChunkInt@6fbde88a
1630
+ println(r.read(-1)) // 1
1631
+ // 1
1632
+ r.reset()
1633
+ println(r.read(-1)) // 1 (back to the beginning)
1634
+ // 1
1635
+ ```
1636
+
1637
+ ## Integration with Stream
1638
+
1639
+ `Reader` is the compilation target of `Stream`. When you call a terminal operation, the stream compiles to a `Reader`, which is then consumed.
1640
+
1641
+ For cross-platform manual pulling, use caller-owned `Stream#startAsync` or bracketed `Stream#useReaderAsync`. `startAsync` transfers ownership to you, so you must await `close()` on every exit path; `useReaderAsync` retains ownership and closes automatically on success, failure, or cancellation. The JVM-only `Stream#start` returns a scoped blocking reader owned by its scope:
1642
+
1643
+ ```scala
1644
+ import zio.blocks.streams.*
1645
+ import zio.blocks.streams.io.Reader
1646
+ import zio.blocks.scope.*
1647
+
1648
+ Scope.global.scoped { scope =>
1649
+ import scope.*
1650
+
1651
+ val reader: $[Reader.SyncReader[Int]] = Stream.range(1, 6).start(using scope)
1652
+
1653
+ $(reader) { r =>
1654
+ def drain(): Unit = {
1655
+ val v = r.read(-1)
1656
+ if (v != -1) {
1657
+ println(v) // prints 1, 2, 3, 4, 5
1658
+ drain()
1659
+ }
1660
+ }
1661
+ drain()
1662
+ }
1663
+ // reader is closed automatically when scope exits
1664
+ }
1665
+ ```
1666
+
1667
+ :::caution
1668
+ Avoid holding references to a `SyncReader` obtained via `Stream#start` outside its [`Scope`](../../resource-management/scope.md). The scope guarantees cleanup; escaping the reader defeats that guarantee.
1669
+ :::
1670
+
1671
+ ## Integration with Sink
1672
+
1673
+ `Reader` and `Sink` are dual: `Reader` is the source, and `Sink` is the consumer. A terminal compiles the stream to the reader kind required by the graph, hands that reader to the sink's drain, and retains ownership of it. On the JVM, plain terminals such as `run` use the blocking `SyncReader` path when the graph is synchronous and bridge genuine asynchronous boundaries at the final edge. Cross-platform `runAsync` drains an `AsyncReader` without blocking. Both terminal families close the owned reader on success, typed failure, defect, or cancellation.
1674
+
1675
+ The sink repeatedly pulls from its reader until end-of-stream, transforming the sequence of elements into a result of type `Z`. This kind-selected drain is an implementation detail; callers choose it through `run` or `runAsync` rather than invoking a sink drain method directly.
1676
+
1677
+ For example, `Sink.collectAll` drains all elements and returns them as a `Chunk`:
1678
+
1679
+ ```scala
1680
+ import zio.blocks.streams._
1681
+
1682
+ val result = Stream.range(1, 10)
1683
+ .run(Sink.collectAll[Int])
1684
+ // result: Either[Nothing, Chunk[Int]] = Right(
1685
+ // IndexedSeq(1, 2, 3, 4, 5, 6, 7, 8, 9)
1686
+ // )
1687
+ ```
1688
+
1689
+ ## Implementation Notes
1690
+
1691
+ Understand the design choices and mechanisms that power `Reader`:
1692
+
1693
+ ### Sentinel Protocol
1694
+
1695
+ The `read(sentinel)` method uses a caller-chosen sentinel value to signal end-of-stream. This avoids the allocation and boxing of wrapping results in `Option` or `Either`. The sentinel must be a value that never appears as a real element.
1696
+
1697
+ The contract has three parts, and it is the same on both reader kinds:
1698
+
1699
+ 1. **The caller owns the sentinel.** The reader never invents one. Pick a value that cannot occur in your data — `null` is the usual choice for reference elements.
1700
+ 2. **The sentinel travels in the widened carrier.** A primitive pull returns the lane's widened type, not the element type, precisely so a value outside the element's domain is available to spend as the sentinel. `readInt` takes and returns `Long`; `readChar`, `readShort`, and `readBoolean` take and return `Int`; `readFloat` takes and returns `Double`. `readByte()` is the exception that proves the rule: it takes no sentinel parameter because it yields unsigned bytes in `0..255` and can reserve `-1` permanently.
1701
+ 3. **Getting the sentinel back means exhausted, and nothing else.** It is not an error signal. Failures arrive as thrown exceptions on a `SyncReader` and as failed `Async` values on an `AsyncReader`.
1702
+
1703
+ Two lanes sit outside that arrangement. There is no `Long` value and no `Double` bit pattern left over to reserve — the carrier is the element type itself, so every candidate sentinel is also legitimate data. **The `Long` and `Double` lanes therefore use no sentinel.** `readLong` and `readDouble` still take a sentinel parameter, for symmetry with the five other sentinel-taking pulls, but nothing can safely fill it; the library never relies on it. Instead those lanes detect end-of-stream by count: a length-one `readLongs` or `readDoubles` whose returned count is negative. That is what makes those two lanes fully lossless — every `Long` value and every `Double` bit pattern stays readable as data.
1704
+
1705
+ For the per-lane end-of-stream detail, including which carrier each lane widens to, see [Zero-Boxing Streams](../execution-and-compatibility/zero-boxing.md), which owns that table.
1706
+
1707
+ ### JVM Type Dispatch
1708
+
1709
+ `Reader` dispatches on `jvmType` to choose between unboxed and boxed pull paths. This is a physical contract: `JvmType.Byte`, for example, means `readByte` is supported and yields this reader's elements, even if covariance has widened its static type to `Reader[AnyVal]`. Type-preserving wrappers and widening operations preserve a known lane; only an actually unknown or mixed representation falls back to `AnyRef`. Subclasses with primitive specialization override `jvmType`:
1710
+
1711
+ ```scala
1712
+ abstract class Reader[+Elem] {
1713
+ def jvmType: JvmType = JvmType.AnyRef
1714
+ }
1715
+ ```
1716
+
1717
+ `JvmType` has nine lanes: the eight JVM primitives — `Boolean`, `Byte`, `Char`, `Short`, `Int`, `Long`, `Float`, `Double` — and `AnyRef` for everything else. The eight primitive tags map exactly to `readBoolean`, `readByte`, `readChar`, `readShort`, `readInt`, `readLong`, `readFloat`, and `readDouble`; `AnyRef` is the ninth, and it is the only lane on which all eight of those methods work, because it satisfies them by pulling boxed and converting. For example, a `SyncReader[Int]` backed by a `Chunk[Int]` reports `JvmType.Int`, so consumers may use `readInt`; a pull belonging to another lane throws `UnsupportedOperationException` unless that particular reader happens to implement it too.
1718
+
1719
+ The lane is a property of the reader, not of the kind. A `SyncReader` and the `AsyncReader` it becomes under `toAsync` report the same `jvmType`, and asynchronous readers expose the corresponding values through `Async`.
1720
+
1721
+ ### Thread Safety
1722
+
1723
+ Readers are single-consumer cursors, not concurrent work queues. In particular, do not overlap pulls on an `AsyncReader`; await one operation before beginning another.
1724
+
1725
+ ## Running the Examples
1726
+
1727
+ All code from this guide is available as runnable examples in the `streams-examples` module. Follow these steps to run them:
1728
+
1729
+ **Step 1** — Clone the repository and navigate to the project:
1730
+
1731
+ ```bash
1732
+ git clone https://github.com/zio/zio-blocks.git
1733
+ cd zio-blocks
1734
+ ```
1735
+
1736
+ **Step 2** — Run individual examples with sbt:
1737
+
1738
+ ### Basic Reader Construction
1739
+
1740
+ This example demonstrates the most common reader factories: `Reader.fromChunk`, `Reader.fromIterable`, `Reader.fromRange`, and `Reader.single`. Embed the source:
1741
+
1742
+ ```scala title="streams-examples/src/main/scala/reader/ReaderBasicConstructionExample.scala"
1743
+ package reader
1744
+
1745
+ import zio.blocks.chunk.Chunk
1746
+ import zio.blocks.streams.io.Reader
1747
+
1748
+ /**
1749
+ * Demonstrates the most common Reader factories: fromChunk, fromIterable,
1750
+ * fromRange, single, and unfold. Each reader is drained manually with read() to
1751
+ * show how to consume elements.
1752
+ */
1753
+ object ReaderBasicConstructionExample extends App {
1754
+
1755
+ println("=== Reader.fromChunk ===")
1756
+ val chunkReader = Reader.fromChunk(Chunk(10, 20, 30))
1757
+ var v = chunkReader.read(-1)
1758
+ while (v != -1) {
1759
+ println(s"Read: $v")
1760
+ v = chunkReader.read(-1)
1761
+ }
1762
+
1763
+ println("\n=== Reader.fromRange ===")
1764
+ val rangeReader = Reader.fromRange(1 to 3)
1765
+ v = rangeReader.read(-1)
1766
+ while (v != -1) {
1767
+ println(s"Read: $v")
1768
+ v = rangeReader.read(-1)
1769
+ }
1770
+
1771
+ println("\n=== Reader.fromIterable ===")
1772
+ val listReader = Reader.fromIterable(List("a", "b", "c"))
1773
+ var sv = listReader.read(null: String)
1774
+ while (sv != null) {
1775
+ println(s"Read: $sv")
1776
+ sv = listReader.read(null: String)
1777
+ }
1778
+
1779
+ println("\n=== Reader.single ===")
1780
+ val singleReader = Reader.single(42)
1781
+ println(s"Read: ${singleReader.read(-1)}")
1782
+ println(s"Read again (closed): ${singleReader.read(-1)}")
1783
+
1784
+ println("\n=== Reader.unfold ===")
1785
+ val unfoldReader = Reader.unfold(1) { s =>
1786
+ if (s > 3) None else Some((s * 10, s + 1))
1787
+ }
1788
+ v = unfoldReader.read(-1)
1789
+ while (v != -1) {
1790
+ println(s"Read: $v")
1791
+ v = unfoldReader.read(-1)
1792
+ }
1793
+
1794
+ println("\n=== Reader state ===")
1795
+ val stateReader = Reader.fromChunk(Chunk(5, 6))
1796
+ println(s"readable before: ${stateReader.readable()}")
1797
+ stateReader.read(-1)
1798
+ println(s"readable after one read: ${stateReader.readable()}")
1799
+ stateReader.read(-1)
1800
+ println(s"readable after exhaustion: ${stateReader.readable()}")
1801
+ println(s"isClosed: ${stateReader.isClosed}")
1802
+ }
1803
+ ```
1804
+
1805
+ Run it with:
1806
+
1807
+ ```bash
1808
+ sbt "streams-examples/runMain reader.ReaderBasicConstructionExample"
1809
+ ```
1810
+
1811
+ ### Primitive Specialization and Bulk Operations
1812
+
1813
+ This example shows how primitive readers avoid boxing through `Reader#jvmType` dispatch, and demonstrates `Reader#readAll` and `Reader#skip` for bulk operations. Embed the source:
1814
+
1815
+ ```scala title="streams-examples/src/main/scala/reader/ReaderPrimitiveSpecializationExample.scala"
1816
+ package reader
1817
+
1818
+ import zio.blocks.chunk.Chunk
1819
+ import zio.blocks.streams.io.Reader
1820
+
1821
+ /**
1822
+ * Demonstrates primitive specialization in readers. When a Reader is backed by
1823
+ * primitive types (Int, Long, Float, Double), specialized factory methods like
1824
+ * singleInt, singleLong, etc. avoid boxing entirely. This example also shows
1825
+ * readAll for bulk consumption and skip for advancing the reader.
1826
+ */
1827
+ object ReaderPrimitiveSpecializationExample extends App {
1828
+
1829
+ println("=== singleInt (zero-boxed) ===")
1830
+ val intReader = Reader.singleInt(42)
1831
+ println(s"Read: ${intReader.read(-1)}")
1832
+
1833
+ println("\n=== singleLong (zero-boxed) ===")
1834
+ val longReader = Reader.singleLong(9999999999L)
1835
+ println(s"Read: ${longReader.read(Long.MaxValue)}")
1836
+
1837
+ println("\n=== singleFloat (zero-boxed) ===")
1838
+ val floatReader = Reader.singleFloat(3.14f)
1839
+ println(s"Read: ${floatReader.readFloat(Float.MaxValue)}")
1840
+
1841
+ println("\n=== singleDouble (zero-boxed) ===")
1842
+ val doubleReader = Reader.singleDouble(2.718)
1843
+ println(s"Read: ${doubleReader.read(Double.MaxValue)}")
1844
+
1845
+ println("\n=== readAll: bulk drain to Chunk ===")
1846
+ val bulkReader = Reader.fromChunk(Chunk(1, 2, 3, 4, 5))
1847
+ val allElements = bulkReader.readAll()
1848
+ println(s"All elements: $allElements")
1849
+
1850
+ println("\n=== skip: discard n elements ===")
1851
+ val skipReader = Reader.fromRange(10 to 15)
1852
+ skipReader.skip(2)
1853
+ // Should now read from 12 onward
1854
+ var v = skipReader.read(-1)
1855
+ val remaining = scala.collection.mutable.ArrayBuffer[Int]()
1856
+ while (v != -1) {
1857
+ remaining += v
1858
+ v = skipReader.read(-1)
1859
+ }
1860
+ println(s"After skipping 2: ${remaining.toList}")
1861
+
1862
+ println("\n=== reset: rewind to beginning ===")
1863
+ val resetReader = Reader.fromChunk(Chunk("x", "y", "z"))
1864
+ var elem = resetReader.read(null: String)
1865
+ println(s"First read: $elem")
1866
+ resetReader.reset()
1867
+ elem = resetReader.read(null: String)
1868
+ println(s"After reset: $elem")
1869
+
1870
+ println("\n=== readable: check if elements remain ===")
1871
+ val checkReader = Reader.fromChunk(Chunk(100, 200))
1872
+ println(s"readable before read: ${checkReader.readable()}")
1873
+ checkReader.read(-1)
1874
+ println(s"readable after one read: ${checkReader.readable()}")
1875
+ checkReader.read(-1)
1876
+ println(s"readable after exhaustion: ${checkReader.readable()}")
1877
+ }
1878
+ ```
1879
+
1880
+ Run it with:
1881
+
1882
+ ```bash
1883
+ sbt "streams-examples/runMain reader.ReaderPrimitiveSpecializationExample"
1884
+ ```
1885
+
1886
+ ### Composition and Resource Management
1887
+
1888
+ This example demonstrates reader composition with `Reader#++`, resource cleanup with `Reader#withRelease`, and integration with `Stream.start` for manual pulling. Embed the source:
1889
+
1890
+ ```scala title="streams-examples/src/main/scala/reader/ReaderCompositionExample.scala"
1891
+ package reader
1892
+
1893
+ import zio.blocks.chunk.Chunk
1894
+ import zio.blocks.streams.io.Reader
1895
+ import zio.blocks.streams.Stream
1896
+ import zio.blocks.scope.Scope
1897
+
1898
+ /**
1899
+ * Demonstrates reader composition with ++ (concat), resource cleanup with
1900
+ * withRelease, and integration with Stream.start for manual element-by-element
1901
+ * pulling within a Scope.
1902
+ */
1903
+ object ReaderCompositionExample extends App {
1904
+
1905
+ println("=== concat: ++ operator ===")
1906
+ val r1 = Reader.fromChunk(Chunk(1, 2, 3))
1907
+ val r2 = Reader.fromChunk(Chunk(4, 5, 6))
1908
+ val combined = r1 ++ r2
1909
+
1910
+ var v = combined.read(-1)
1911
+ val allCombined = scala.collection.mutable.ArrayBuffer[Int]()
1912
+ while (v != -1) {
1913
+ allCombined += v
1914
+ v = combined.read(-1)
1915
+ }
1916
+ println(s"Combined result: ${allCombined.toList}")
1917
+
1918
+ println("\n=== Multiple concat: a ++ b ++ c ===")
1919
+ val ra = Reader.fromChunk(Chunk("a"))
1920
+ val rb = Reader.fromChunk(Chunk("b"))
1921
+ val rc = Reader.fromChunk(Chunk("c"))
1922
+ val multi = ra ++ rb ++ rc
1923
+
1924
+ var sv = multi.read(null: String)
1925
+ val result = scala.collection.mutable.ArrayBuffer[String]()
1926
+ while (sv != null) {
1927
+ result += sv
1928
+ sv = multi.read(null: String)
1929
+ }
1930
+ println(s"Multiple concat: ${result.toList}")
1931
+
1932
+ println("\n=== withRelease: cleanup on close ===")
1933
+ var cleanupCalled = false
1934
+ val resourceReader = Reader.fromChunk(Chunk(10, 20)).withRelease { () =>
1935
+ cleanupCalled = true
1936
+ println(" Cleanup executed!")
1937
+ }
1938
+ var res = resourceReader.read(-1)
1939
+ while (res != -1) {
1940
+ res = resourceReader.read(-1)
1941
+ }
1942
+ resourceReader.close()
1943
+ println(s"Cleanup was called: $cleanupCalled")
1944
+
1945
+ println("\n=== Stream.start: manual pull with Scope ===")
1946
+ Scope.global.scoped { scope =>
1947
+ import scope.*
1948
+
1949
+ // Create a stream and open it for manual pulling
1950
+ val reader: scope.$[Reader.SyncReader[Int]] = Stream.range(1, 6).start(using scope)
1951
+
1952
+ $(reader) { r =>
1953
+ var streamV = r.read(-1)
1954
+ val manualResult = scala.collection.mutable.ArrayBuffer[Int]()
1955
+ while (streamV != -1) {
1956
+ manualResult += streamV
1957
+ streamV = r.read(-1)
1958
+ }
1959
+ println(s"Manual stream pull: ${manualResult.toList}")
1960
+ }
1961
+ // reader is automatically closed when scope exits
1962
+ }
1963
+
1964
+ println("\n=== repeat: infinite reader ===")
1965
+ val infiniteReader = Reader.repeat(99)
1966
+ infiniteReader.setRepeat()
1967
+
1968
+ var repeatCount = 0
1969
+ var repV = infiniteReader.read(-1)
1970
+ while (repeatCount < 3) {
1971
+ println(s"Infinite read $repeatCount: $repV")
1972
+ repV = infiniteReader.read(-1)
1973
+ repeatCount += 1
1974
+ }
1975
+ infiniteReader.close()
1976
+ }
1977
+ ```
1978
+
1979
+ Run it with:
1980
+
1981
+ ```bash
1982
+ sbt "streams-examples/runMain reader.ReaderCompositionExample"
1983
+ ```
1984
+
1985
+ ## See Also
1986
+
1987
+ - [Asynchronous Stream Execution](../execution-and-compatibility/async-execution.md) — how a graph picks its engine, the `*Async` surface, and close ownership from the stream's side
1988
+ - [Platform Differences](../execution-and-compatibility/platform-differences.md#availability-matrix) — which reader operations exist on the JVM, on Scala.js, and on both
1989
+ - [Zero-Boxing Streams](../execution-and-compatibility/zero-boxing.md) — how a primitive lane is chosen, and the per-lane end-of-stream table
1990
+ - [Stream](../core/stream.md) — the operator and terminal reference for the type that compiles to a `Reader`
1991
+ - [Sink](../core/sink.md) — the consumer that drains a `Reader`
1992
+ - [Async](../../async.md#the-pollable-protocol) — `Async[A]`, `Pollable`, and what awaiting an asynchronous result means