Skip to content

Commit f6f7135

Browse files
committed
[GR-19220] Fix crash caused by mutating an array within the .each call block (#2587)
PullRequest: truffleruby/3170
2 parents 518f22a + b94b948 commit f6f7135

File tree

3 files changed

+43
-28
lines changed

3 files changed

+43
-28
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Compatibility:
2828
* Set `@gem_prelude_index` variable on the default load paths (#2586 , @bjfish)
2929
* Do not call `IO#flush` dynamically from `IO#close` (#2594, @gogainda).
3030
* Implement `rb_str_new_static` for C extensions that use it (@aardvark179).
31+
* Rewrote `ArrayEachIteratorNode` and re-introduced `each` specs for MRI parity when mutating arrays whilst iterating, rather than crashing (#2587, @MattAlp)
3132

3233
Performance:
3334

@@ -36,6 +37,7 @@ Performance:
3637
* Removed extra array allocations for method calls in the interpreter to improve warmup performance (@aardvark179).
3738
* Optimize `Dir[]` by sorting entries as they are found and grouping syscalls (#2092, @aardvark179).
3839
* Reduce memory footprint by tracking `VALUE`s created during C extension init separately (@aardvark179).
40+
* Rewrote `ArrayEachIteratorNode` to optimize performance for a constant-sized array and reduce specializations to 1 general case (#2587, @MattAlp)
3941

4042
Changes:
4143

spec/ruby/core/array/each_spec.rb

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
require_relative 'shared/enumeratorize'
44
require_relative '../enumerable/shared/enumeratorized'
55

6-
# Modifying a collection while the contents are being iterated
7-
# gives undefined behavior. See
8-
# http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-core/23633
6+
# Mutating the array while it is being iterated is discouraged as it can result in confusing behavior.
7+
# Yet a Ruby implementation must not crash in such a case, and following the simple CRuby behavior makes sense.
8+
# CRuby simply reads the array storage and checks the size for every iteration;
9+
# like `i = 0; while i < size; yield self[i]; end`
910

1011
describe "Array#each" do
1112
it "yields each element to the block" do
@@ -15,6 +16,34 @@
1516
a.should == [1, 2, 3]
1617
end
1718

19+
it "yields each element to the block even if the array is changed during iteration" do
20+
a = [1, 2, 3, 4, 5]
21+
iterated = []
22+
a.each { |x| iterated << x; a << x+5 if x.even? }
23+
iterated.should == [1, 2, 3, 4, 5, 7, 9]
24+
end
25+
26+
it "yields only elements that are still in the array" do
27+
a = [0, 1, 2, 3, 4]
28+
iterated = []
29+
a.each { |x| iterated << x; a.pop if x.even? }
30+
iterated.should == [0, 1, 2]
31+
end
32+
33+
it "yields elements based on an internal index" do
34+
a = [0, 1, 2, 3, 4]
35+
iterated = []
36+
a.each { |x| iterated << x; a.shift if x.even? }
37+
iterated.should == [0, 2, 4]
38+
end
39+
40+
it "yields the same element multiple times if inserting while iterating" do
41+
a = [1, 2]
42+
iterated = []
43+
a.each { |x| iterated << x; a.unshift(0) if a.size == 2 }
44+
iterated.should == [1, 1, 2]
45+
end
46+
1847
it "yields each element to a block that takes multiple arguments" do
1948
a = [[1, 2], :a, [3, 4]]
2049
b = []

src/main/java/org/truffleruby/core/array/ArrayEachIteratorNode.java

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
package org.truffleruby.core.array;
1111

1212
import com.oracle.truffle.api.TruffleSafepoint;
13+
import com.oracle.truffle.api.profiles.IntValueProfile;
1314
import org.truffleruby.core.array.library.ArrayStoreLibrary;
1415
import org.truffleruby.core.proc.RubyProc;
1516
import org.truffleruby.language.RubyBaseNode;
1617

1718
import com.oracle.truffle.api.CompilerDirectives;
18-
import com.oracle.truffle.api.dsl.Bind;
1919
import com.oracle.truffle.api.dsl.Cached;
2020
import com.oracle.truffle.api.dsl.ImportStatic;
2121
import com.oracle.truffle.api.dsl.ReportPolymorphism;
@@ -42,35 +42,19 @@ public static ArrayEachIteratorNode create() {
4242
public abstract RubyArray execute(RubyArray array, RubyProc block, int startAt,
4343
ArrayElementConsumerNode consumerNode);
4444

45-
@Specialization(
46-
guards = { "array.size == 1", "startAt == 0" },
47-
limit = "storageStrategyLimit()")
48-
protected RubyArray iterateOne(RubyArray array, RubyProc block, int startAt, ArrayElementConsumerNode consumerNode,
49-
@Bind("array.store") Object store,
50-
@CachedLibrary("store") ArrayStoreLibrary stores) {
51-
52-
consumerNode.accept(array, block, stores.read(store, 0), 0);
53-
54-
if (array.size > 1) {
55-
// Implicitly profiles through lazy node creation
56-
return getRecurseNode().execute(array, block, 1, consumerNode);
57-
}
58-
59-
return array;
60-
}
61-
62-
@Specialization(
63-
guards = { "array.size != 1" },
64-
limit = "storageStrategyLimit()")
45+
@Specialization(limit = "storageStrategyLimit()")
6546
protected RubyArray iterateMany(RubyArray array, RubyProc block, int startAt, ArrayElementConsumerNode consumerNode,
66-
@Bind("array.store") Object store,
67-
@CachedLibrary("store") ArrayStoreLibrary stores,
47+
// Checkstyle: stop -- Verified @Bind is not necessary here due to using `Library#accepts()`.
48+
@CachedLibrary("array.store") ArrayStoreLibrary stores,
49+
// Checkstyle: resume
6850
@Cached LoopConditionProfile loopProfile,
51+
@Cached("createIdentityProfile()") IntValueProfile arraySizeProfile,
6952
@Cached ConditionProfile strategyMatchProfile) {
7053
int i = startAt;
7154
try {
72-
for (; loopProfile.inject(i < array.size); i++) {
73-
if (strategyMatchProfile.profile(stores.accepts(array.store))) {
55+
for (; loopProfile.inject(i < arraySizeProfile.profile(array.size)); i++) {
56+
Object store = array.store;
57+
if (strategyMatchProfile.profile(stores.accepts(store))) {
7458
consumerNode.accept(array, block, stores.read(store, i), i);
7559
} else {
7660
return getRecurseNode().execute(array, block, i, consumerNode);

0 commit comments

Comments
 (0)