From f71cd175ada90ae046849d7ae1314189edeb1c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Thallis?= Date: Wed, 16 Sep 2026 15:02:19 -0300 Subject: [PATCH] Take the block's default exit when a condition's arithmetic fails A flow author's Case-block condition can divide by zero (or otherwise fault) at runtime even when every operand is a valid number. `Kernel.//2` then raises `ArithmeticError`, which is neither an `Expression.Error` nor a `RuntimeError`, so it escaped `evaluate_expression_block/2`'s rescue and crashed the calling process instead of just failing the condition. Treat a runtime arithmetic fault like any other unusable condition value: `Exit.evaluate/2` and `Case.evaluate_outgoing/5` already fall through to the block's default exit for an `{:error, _, _}` result, so the run continues. Also reorder `Exit.evaluate/2`'s case clauses: the `{:error, reason, bad_parts}` clause was already unreachable, shadowed by the preceding `other when not is_boolean(other)` guard. The typed error return now makes the dead clause visible to the compiler, so move the specific clause first. Co-Authored-By: Claude Opus 4.8 --- lib/flow_runner.ex | 14 ++++++++++++++ lib/flow_runner/spec/exit.ex | 8 ++++---- test/block_test.exs | 22 ++++++++++++++++++++++ test/flow_runner/expression_mode_test.exs | 8 ++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/lib/flow_runner.ex b/lib/flow_runner.ex index 5e137e1..4124953 100644 --- a/lib/flow_runner.ex +++ b/lib/flow_runner.ex @@ -123,6 +123,17 @@ defmodule FlowRunner do ) end + @doc """ + Evaluates a block or condition expression. + + Runtime arithmetic in an expression can fail even when the operands are valid + numbers, division by zero being the canonical case, and + `Expression.evaluate_block!/4` surfaces that as an `ArithmeticError`. Rather than + let it crash the calling process, we treat it like any other unusable condition + value: the caller (`FlowRunner.Spec.Exit.evaluate/2`, + `FlowRunner.Spec.Blocks.Case.evaluate_outgoing/5`) falls through to the block's + default exit. + """ @impl FlowRunner.Contract def evaluate_expression_block(expression, %Context{} = context) do Expression.evaluate_block!( @@ -134,6 +145,9 @@ defmodule FlowRunner do rescue e in Expression.Error -> {:error, e.type, e.message} + + e in ArithmeticError -> + {:error, :arithmetic, Exception.message(e)} end defdelegate fetch_resource_by_uuid(container, uuid), to: FlowRunner.Spec.Container diff --git a/lib/flow_runner/spec/exit.ex b/lib/flow_runner/spec/exit.ex index a8a6d78..04ce636 100644 --- a/lib/flow_runner/spec/exit.ex +++ b/lib/flow_runner/spec/exit.ex @@ -35,16 +35,16 @@ defmodule FlowRunner.Spec.Exit do test = exit.test || "" case FlowRunner.evaluate_expression_block(test, context) |> extract_value() do - other when not is_boolean(other) -> + {:error, reason, bad_parts} -> Logger.info( - "Expression '#{exit.test}' returned #{inspect(other)} when expecting a boolean last_block_uuid=#{context.last_block_uuid}" + "Expression '#{exit.test}' returned parsing error #{inspect(reason)} when hit #{inspect(bad_parts)} last_block_uuid=#{context.last_block_uuid}" ) false - {:error, reason, bad_parts} -> + other when not is_boolean(other) -> Logger.info( - "Expression '#{exit.test}' returned parsing error #{inspect(reason)} when hit #{inspect(bad_parts)} last_block_uuid=#{context.last_block_uuid}" + "Expression '#{exit.test}' returned #{inspect(other)} when expecting a boolean last_block_uuid=#{context.last_block_uuid}" ) false diff --git a/test/block_test.exs b/test/block_test.exs index f889e56..dc33718 100644 --- a/test/block_test.exs +++ b/test/block_test.exs @@ -152,6 +152,28 @@ defmodule BlockTest do Block.evaluate_exits(block, context) end + test "an exit whose test raises a runtime arithmetic error falls through to the default exit" do + context = %Context{ + vars: %{"score" => 10, "total" => 0} + } + + block = %Block{ + exits: [ + %Exit{ + uuid: "b586afa7-0097-4805-9951-f6d3156c08db", + test: "score / total > 1" + }, + %Exit{ + uuid: "cbf4382f-0ce0-426b-9a3c-40cbe84dd081", + default: true + } + ] + } + + assert {:ok, %Exit{uuid: "cbf4382f-0ce0-426b-9a3c-40cbe84dd081"}} = + Block.evaluate_exits(block, context) + end + test "fetch default block" do # 1. With NO default exit context = %Context{ diff --git a/test/flow_runner/expression_mode_test.exs b/test/flow_runner/expression_mode_test.exs index 0ffcbdf..232dff7 100644 --- a/test/flow_runner/expression_mode_test.exs +++ b/test/flow_runner/expression_mode_test.exs @@ -48,6 +48,14 @@ defmodule FlowRunner.ExpressionModeTest do assert FlowRunner.evaluate_expression_block("age", context(%{"age" => "30"}, :v3)) == "30" end + + test "a runtime arithmetic failure returns an :arithmetic error instead of raising" do + assert {:error, :arithmetic, _message} = + FlowRunner.evaluate_expression_block( + "score / total", + context(%{"score" => 10, "total" => 0}, :v2) + ) + end end describe "evaluate_expression_as_string!/2" do