for May Iterate Something Whose Step PausesStatus: Accepted
Date: September 20, 2026
Related: ADR-105 D1 (Seq[T], and the words after it that say
what one step may do), ADR-025 D6 (throws on a step, the shape
this copies), ADR-121 D4 (a worker’s reply as something that can
be awaited), ADR-055 §5 (binding tokio is rejected),
ADR-303 (the I/O layer this language already has),
ADR-119 D2 (the no_std target a dependency would have to fit),
Part III 17.1 (a stream larger than memory is a for
over io::lines())
Before this record, for line in io::lines() { … } lowered to this:
for line in io::lines().await {
let line = line?;
…
}
The call awaited, because io::lines is not sync. The step did not:
Lines::next was an Iterator::next over a BufRead, which blocks the thread
it is on for as long as the line takes. So a for over standard input held its
thread for the whole loop, and at user_parallelism = yes that is a thread the
pool could have had.
It is a question about what a for is in this language and not about how
to build one. Rust has no stable Stream, so a step that pauses
is while let Some(x) = s.next().await below, and deciding that a for may
mean that is a decision about the construct.
The shape already existed. ADR-025 D6 put a word on a type
that changes how the emitter writes a for’s step — throws, answering can it
fail. A second word on the same type, answering does it pause, is one more of
the same thing rather than a new idea. And a for that cannot pause makes
user_parallelism = yes a promise the standard library itself breaks.
Seq says whether its step pauses, and a for over one gives its thread up.The word is pauses, after the sequence and beside sync and throws:
signature = "() -> Seq[String] pauses throws"
A for over such a sequence is written as one that awaits each step. Nothing is
refused and nothing is reported: a for over a stream is an ordinary program,
and the whole of this is that it stops holding a thread it does not need.
Three states and not two, and that is the decision rather than a detail. The
absence of sync was already readable as a step may pause, and taking it as
the claim would have been cheaper by one word. It is wrong, and the two ways of
being wrong are not symmetric:
.next().await
does not compile, and what a reader meets is rustc about a file nobody
wrote — Part III C.1 exactly, and C.4’s correct
program refused.So the claim is positive. sync is a promise (it does not pause), pauses is
a warning (it does), and neither is nobody said — which is what a map’s
step is, since it runs the lambda and what the lambda does is the caller’s. A
ledger that writes both words has said two things about one step and is not read
as either.
The verdict travels with the type, so naming the stream first is the same loop:
let stream = io::lines() and for line in stream await exactly as the inline
form does.
Which sequences pause is a claim in the ledger about a type, and the emitter
has no types (ADR-288). So the checker records the loops whose step
pauses by the byte the statement starts at and hands the set over — the same
arrangement fallible_loops has, for the same reason and stated in the same
place.
The two compose without either knowing about the other: a loop that both pauses
and can fail awaits its step and takes the ? inside, which is one ledger word
apart from a loop that does neither.
std’s own.There is no trait yet, and that is the scope rather than a gap. Of
Seq[T]’s six producers in std.contracts — io::lines, HashMap::keys,
HashMap::values, String::chars, Vec::drain, HashMap::drain — five are
sync and exactly one can pause. What the emitted while let wants is an
inherent async fn next on one concrete type; a trait over many is owed the day
there is a second pausing producer, and is one trait rather than a language
feature.
And it is not a foreign one, which was asked and is answered here so that it is not asked again:
tokio the runtime is already rejected, by ADR-055 §5:
“It brings an executor and an I/O layer, and ADR-303 built the
second one already. Two event loops in one process is the state D7 treats as a
hazard.” Nothing in this question reopens that.tokio_stream::Stream is not tokio’s own trait — it is a re-export of
futures_core::Stream — so the dependency actually under discussion is
futures-core: a trait definition, no_std-friendly, no executor. A far
smaller thing than the runtime, and still the wrong place for it.Seq[T]’s consumers — collect, count,
nth, join, map, filter — which are Iterator’s below and need pausing
twins. That work is the same whichever trait sits on top, and futures-core
does not shorten it; futures::StreamExt would, at the price of Pin/Poll
in our signatures and an ADR-119 D2 no_std target we no longer
control.So the trait is ours when it arrives, which is the move Joined made in
ADR-280. futures_core::Stream earns its place only at a foreign
boundary, where a described crate hands us a stream and its trait is the lingua
franca — never in the core.
io::lines reads a chunk at a time, and the line endings are found here.The first producer, and the one D1 was asked for. A step of it is a read of standard input on an I/O worker, awaited, which is ADR-121 D4’s shape.
A chunk and not a line, and this is the difference between a change that pays
for itself and one that does not: a hop to a worker and back costs a wake-up, and
paying one per line would make io::lines slower than the blocking reader it
replaces. So Lines asks for a buffer, hands out the lines in it, and asks again
when it runs out — one hop per buffer.
One read per hop and not a loop to fill the buffer. Short is not the end of a stream, and a caller that asked for a chunk wants whatever is there now: a pipe that produces a line a second is a program that prints a line a second, and filling 8 KiB first would make it a program that prints nothing for two hours.
What is handed out is dropped from the buffer before the next chunk goes in, so
what is held is the size of the unread remainder and not of everything the
stream ever carried. Part III 17.1’s a stream larger
than memory is a for over this stays true, which is the property this could
most easily have lost.
BufRead::lines’ own rules are kept, because programs were written against them:
the newline is not part of the line, a \r before it is not either, a blank line
is a line, and a final line without a newline is still a line — but only once the
stream is over, or a chunk boundary would split a line at whatever byte it landed
on.
Seq[T]’s six consumers split on what they hand back, and the split is the
rule rather than a list of names:
collect, count, nth, join hand back a value, so each is
a loop around the step. Both of the receiver’s words reach them: the walk
pauses (D1) and, where a step can fail, the walk can fail. std writes them
as inherent async fns on Lines.map and filter hand back another sequence, whose steps would
pause. That is the trait D3 defers, so the call is refused from the lowering
with the loop as the way out:error: `map` walks a sequence whose step pauses, and a `for` is the only walk
of one this compiler can write yet (ADR-172 D5). Write the loop -
`for line in io::lines() { … }` - and do inside it what this was going to
do afterwards
--> app.nika:4:5
4 | let ls = io::lines().map fn { a }
^
A refusal from the lowering and no number, which is ADR-171 §4’s rule: a number is a promise the rule stays, and this is this compiler cannot build that yet rather than the language forbids it. The day the lazy ones exist, the sentence goes and nothing in the catalogue has to be withdrawn.
The failing half is NK2701, the same code the for gets.
ADR-025 D1 says a step that can fail fails the enclosing function
and that nothing marks the call; a count is a written call, so the rule
already covered it and only the compiler did not. The message says a step of
what count walks rather than a turn of this loop, because that is what a
reader is looking at.
And that half’s absence would be a wrong answer, not a missing feature. A
Lines that were an Iterator over Result<String, …> would have
io::lines().count() count the failures as lines under a ledger that says
-> i64, and collect hand back a list of results typed as a list of strings —
the bug class Part I 6.4 refuses by name.
One thing the emitter had to stop guessing. collect is written
.collect::<Vec<_>>() below, because Rust’s needs to be told what to build and
this emitter has no types. Lines::collect takes no type at all, so the
turbofish is skipped exactly where the site pauses — an Iterator::collect
never does, which makes that the difference rather than the method’s name.
A for over standard input gives its thread up. At user_parallelism = yes
that is a thread the pool gets back, and at no it is the same program it was.
What it costs: one word in the ledger’s type language, one set threaded from
the checker to the emitter, and one buffer per stream. Lines stops being an
Iterator, which nothing outside std could see — the ledger names the type and
a program writes for.
What it does not change: every other loop. A container, a range, and a sequence that says nothing keep the shape they had, which is asserted rather than hoped for.
map and filter over a pausing sequence hand back
one whose steps pause, which is the trait D3 defers. D5 refuses them meanwhile
and #182 carries it.for over a channel, which is the other shape a pausing step could have
and which nothing asks for yet.