From 93752db81999c266c53d091f6fe52ab80b82fb9f Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Tue, 5 May 2026 23:02:03 +0200 Subject: [PATCH 1/3] Fix infinite loop in Stream.cycle when enumerable reduce call yields no elements --- lib/elixir/lib/stream.ex | 2 +- lib/elixir/test/elixir/stream_test.exs | 37 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/elixir/lib/stream.ex b/lib/elixir/lib/stream.ex index 1780738efd4..fe6196f82de 100644 --- a/lib/elixir/lib/stream.ex +++ b/lib/elixir/lib/stream.ex @@ -1437,7 +1437,7 @@ defmodule Stream do do_cycle(cycle, [], cycle, fun.(element, acc), fun) {_, []} -> - do_cycle(cycle, [], cycle, {:cont, acc}, fun) + do_cycle(check_cycle_first_element(cycle), [], cycle, {:cont, acc}, fun) end end diff --git a/lib/elixir/test/elixir/stream_test.exs b/lib/elixir/test/elixir/stream_test.exs index e747e868b40..2260b3cd5c6 100644 --- a/lib/elixir/test/elixir/stream_test.exs +++ b/lib/elixir/test/elixir/stream_test.exs @@ -281,6 +281,43 @@ defmodule StreamTest do [1, 1, 1, 1, 1] end + test "cycle/1 raises and does not infinite-loop when a subsequent reduce yields no elements" do + {:ok, agent} = Agent.start_link(fn -> 0 end) + + stream = + Stream.resource( + fn -> Agent.get_and_update(agent, fn n -> {n, n + 1} end) end, + fn + 0 -> {[:a], :done} + _ -> {:halt, :ok} + end, + fn _ -> :ok end + ) + + parent = self() + + task = + Task.async(fn -> + try do + Stream.cycle(stream) |> Enum.take(3) + rescue + e in ArgumentError -> send(parent, {:raised, e.message}) + end + end) + + try do + case Task.yield(task, 1000) || Task.shutdown(task, :brutal_kill) do + {:ok, _} -> + assert_received {:raised, "cannot cycle over an empty enumerable"} + + nil -> + flunk("Stream.cycle/1 entered an unbounded loop with no progress") + end + after + Agent.stop(agent) + end + end + test "dedup/1 is lazy" do assert lazy?(Stream.dedup([1, 2, 3])) end From 4559989ffb205684b95669c11736510741150fd7 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Wed, 6 May 2026 11:07:02 +0200 Subject: [PATCH 2/3] Address review: raise RuntimeError on subsequent empty cycle, simplify test --- lib/elixir/lib/stream.ex | 20 +++++++++++++-- lib/elixir/test/elixir/stream_test.exs | 35 ++++++++------------------ 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/lib/elixir/lib/stream.ex b/lib/elixir/lib/stream.ex index fe6196f82de..a678b625ce8 100644 --- a/lib/elixir/lib/stream.ex +++ b/lib/elixir/lib/stream.ex @@ -1437,7 +1437,7 @@ defmodule Stream do do_cycle(cycle, [], cycle, fun.(element, acc), fun) {_, []} -> - do_cycle(check_cycle_first_element(cycle), [], cycle, {:cont, acc}, fun) + do_cycle(check_cycle_subsequent_element(cycle), [], cycle, {:cont, acc}, fun) end end @@ -1446,10 +1446,26 @@ defmodule Stream do end defp check_cycle_first_element(reduce) do + check_cycle_non_empty( + reduce, + ArgumentError, + "cannot cycle over an empty enumerable" + ) + end + + defp check_cycle_subsequent_element(reduce) do + check_cycle_non_empty( + reduce, + RuntimeError, + "cycled enumerable became empty after a previous iteration produced elements" + ) + end + + defp check_cycle_non_empty(reduce, exception, message) do fn acc -> case reduce.(acc) do {state, []} when state in [:done, :halted] and elem(acc, 0) != :halt -> - raise ArgumentError, "cannot cycle over an empty enumerable" + raise exception, message other -> other diff --git a/lib/elixir/test/elixir/stream_test.exs b/lib/elixir/test/elixir/stream_test.exs index 2260b3cd5c6..e6f94b3f2c4 100644 --- a/lib/elixir/test/elixir/stream_test.exs +++ b/lib/elixir/test/elixir/stream_test.exs @@ -281,12 +281,16 @@ defmodule StreamTest do [1, 1, 1, 1, 1] end - test "cycle/1 raises and does not infinite-loop when a subsequent reduce yields no elements" do - {:ok, agent} = Agent.start_link(fn -> 0 end) + test "cycle/1 raises when a subsequent reduce yields no elements" do + Process.put(:cycle_counter, 0) stream = Stream.resource( - fn -> Agent.get_and_update(agent, fn n -> {n, n + 1} end) end, + fn -> + n = Process.get(:cycle_counter) + Process.put(:cycle_counter, n + 1) + n + end, fn 0 -> {[:a], :done} _ -> {:halt, :ok} @@ -294,28 +298,9 @@ defmodule StreamTest do fn _ -> :ok end ) - parent = self() - - task = - Task.async(fn -> - try do - Stream.cycle(stream) |> Enum.take(3) - rescue - e in ArgumentError -> send(parent, {:raised, e.message}) - end - end) - - try do - case Task.yield(task, 1000) || Task.shutdown(task, :brutal_kill) do - {:ok, _} -> - assert_received {:raised, "cannot cycle over an empty enumerable"} - - nil -> - flunk("Stream.cycle/1 entered an unbounded loop with no progress") - end - after - Agent.stop(agent) - end + assert_raise RuntimeError, + "cycled enumerable became empty after a previous iteration produced elements", + fn -> Stream.cycle(stream) |> Enum.take(3) end end test "dedup/1 is lazy" do From 7f5885988382320fd0ec643013bbc2c0216c9799 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Wed, 6 May 2026 11:28:14 +0200 Subject: [PATCH 3/3] Add explicit cycle/1 test for {:halted, []} on first pass --- lib/elixir/test/elixir/stream_test.exs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/elixir/test/elixir/stream_test.exs b/lib/elixir/test/elixir/stream_test.exs index e6f94b3f2c4..4c38c70abed 100644 --- a/lib/elixir/test/elixir/stream_test.exs +++ b/lib/elixir/test/elixir/stream_test.exs @@ -263,6 +263,10 @@ defmodule StreamTest do Stream.cycle(%{}) |> Enum.to_list() end + assert_raise ArgumentError, "cannot cycle over an empty enumerable", fn -> + Stream.cycle(%HaltAcc{acc: []}) |> Enum.to_list() + end + assert Stream.cycle([1, 2, 3]) |> Stream.take(5) |> Enum.to_list() == [1, 2, 3, 1, 2] assert Enum.take(stream, 5) == [1, 2, 3, 1, 2] end