-
Notifications
You must be signed in to change notification settings - Fork 34
A flaw in JavaScript iteration leaves a weird behavior with take, first, some, etc. #293
Comments
I think this may have been discussed in #122? |
@ljharb Not really. that was more about forwarding coroutine/generator arguments. This is more about the iteration protocol itself. The source iterator needs to be "shut down" prior to yielding the final value for any operation that "knows" when it has a final value. I don't particularly care about the arguments for generators being threaded through. |
For For |
This comment was marked as outdated.
This comment was marked as outdated.
Thanks for raising this. I find the example in the OP compelling, but trading it off against the arguments in #219 - i.e., this is "too clever" and can cause weird behavior in some cases, especially if e.g. closing an async iterator takes a while - I lean towards keeping the current "late closing" behavior, despite the potential footgun. The footgun only arises when re-using the original iterator after invoking an iterator helper on it, and that's just always going to be fraught - unlike observables, iterators are very much designed to be single-consumer. This is only one example of many where re-using the underlying iterator is going to break assumptions. |
Yeah. I had forgotten that the committee made the choice of composing over the iterator rather than the iterable itself. Yeah... I mean, I guess since what we have is a stateful mechanism for traversing values, rather than a monad, there's probably no reason to worry about controlling the lifecycle or cleaning up anything from sources. That's unfortunate. It would have been very, very nice to have implicit teardown from composing async iterables. These things aren't as useful without that. Yeah. I guess this is a non-issue when we're doing async function* fromSomeSocketAPI(url) {
const socketAPI = createSocketAPI(url);
try {
await socketAPI.connected();
for await (const message of socketAPI.messages) {
yield message;
}
} finally {
if (socketAPI.connected) {
socketAPI.close();
}
}
}
const s = fromSomeSocketAPI('wss://something');
s.take(3).forEach(console.log);
// LATER: Oops the socket is still open.
s.return(); |
@bakkot ... Sorry I must be misreading the other threads... I thought it was determined that |
It does, but only on the N+1'th call. That is: function* nat() {
try {
for (let i = 0; true; ++i) yield i;
} finally {
console.log('closing');
}
}
for (let item of nat().take(3)) {
console.log('got', item);
} prints
The let it = nat().take(3);
console.log(it.next()); // prints { done: false, value: 0 }
console.log(it.next()); // prints { done: false, value: 1 }
console.log(it.next()); // prints { done: false, value: 2 }
console.log(it.next()); // prints "closing", then { done: true, value: undefined } (You can actually try this in Chrome if you're on 122 or later, since iterator helpers are now shipping. Only sync helpers, but async helpers will do the same thing as far as the above is concerned.) |
I think that sums it up pretty well. Any iterator (chain) that is iterated by a Only if you are doing something weird, like multiple consumers, or calling |
Yeah... this is a behavior that is more problematic when dealing with the push-based "dual" observable, but only because people are more likely to perform side effects in push-based things Just a heads up: If you end up adding a Iterator.from([1, 2, 3, 4, 5])
.map(n => `${n}.txt`)
.do(fileName => fs.writeFileSync(fileName, 'content'))
.take(3)
.forEach(fileName => console.log(`${fileName} written`)) Which will log three files written, but actually write 4 files. The only way to avoid that would be to have And, of course |
Why would that be confusing? You ran the |
No, that'll only write 3. The contract of You can simulate that today by using Iterator.from([1, 2, 3, 4, 5])
.map(n => `${n}.txt`)
.map(fileName => (console.log(`writing`, fileName), fileName))
.take(3)
.forEach(fileName => console.log(`${fileName} written`)) and prints
just as you'd hope. |
@bakkot Sorry, I was mistaking what was said here to mean that the source would still iterate one more time. You're saying the source would get |
Yup, exactly. (Assuming that the consumer of |
So the only real problem is just the edge case I demonstrated initially, where a user gets a handle to the source of I don't think it'll happen often with Iterable, but it might more often with AsyncIterable. The best thing that was saving us before was that people rarely got a handle to an actual iterator, and mostly dealt with iterables, which start from stratch when iterated. I worry that being able to get a handle to an iterator is going to make people more likely to manually call "next" on it. But I will say, just to be fair, the only people that had this problem with observable were being overly clever. But it did happen enough that we had to do something about it. And the only fix for it was to ensure that the source was finalized before the last value "taken" was emitted. |
You know what? I can't find another language that will finalize a generator/iterator before emitting the last value on I'm closing this. Sorry for the noise. Hopefully, at least, when someone comes because they're doing something weird and they run into this, they'll see this discussion and know more about what's happening. |
(Thank you for your time) |
No problem, thanks for raising. It's good to talk these things through. |
An alternative would be to "lock" the source, and mark it as "consumed", so that only |
TL;DR: Just make sure that take, some, first, etc call
return()
on their source before yielding their last valueThis is just something I wanted to draw to your attention because it's been the source of edge case bugs in observable which is the "dual" of iterable:
Basically, as a principle, a consumer should not be allowed to act on anything from the producer before that producer finalizes (if that producer knows it can finalize). The basic examples of this are all over for "done" (completion/error) paths, but for values it can't be done in JavaScript in a straight forward way because of our avante garde single-step iteration contract.
Put another way, it's impossible for the consuming code to act on the information that iteration is done before the iterable itself cleans up:
And this is a good thing. RxJS is changing our notification ordering to ensure that we adhere to this.
Why
take
,some
,first
etc are a problemThe issue at hand is that in cases where we know we want to end iteration before we yield the next value, there's no way to force the
finally
block to be hit at the moment weyield
. That gives the consumer the chance to iterate N more times, possibly causing more side effects, before the teardown occurs.The problem the code above illustrates is that the source iterator for
take(3)
isn't finalized BEFORE take yields the value. That means that you can stillnext()
off of the source, which is this case will even cause one more file to be copied.This is an admittedly contrived example, but at scale with RxJS observables, which are the "dual" of iterable, for better or worse, I have seen users hit this issue for semi-valid reasons, so I'd caution you all to be too dismissive of this edge case.
The fix for this:
Just ensure that
return()
is called on the iteration source if it exists before yielding the last value you take fromtake
,first
,some
, etc.The root cause:
Unfortunately the ship has sailed on this, but the design of iteration in JavaScript is to blame for this issue. There's no way to
yield
and simultaneously finalize from within an iterable/generator. We might have used{ done: true, value: 'last value' }
to do that, but unfortunately, due to JavaScript's implicitundefined
return, there would be no way to differentiate betweenfunction* () { yeild 1; return undefined; }
andfunction* () { yield 1; }
. Which is whyfor..of
omits{ done: true, value: undefined: }
from iterated values. If JavaScript had gone with a more traditionalmoveNext()
,current()
approach to iteration, this wouldn't have been an issue. 🤷 But there's nothing that can be done there.Related "duality" discussion: ReactiveX/rxjs#7443
The text was updated successfully, but these errors were encountered: