Skip to content

Commit 2c26f77

Browse files
fahchenclaude
andcommitted
test(runner): cover Errors facade and call_enactment exit surface
Add P0-7 caller-safety tests and Runner.Errors facade unit tests. While writing the tests, fix the call_enactment/3 exit-reason unwrapping: GenServer.call/3 wraps the callee's exit reason as {reason, {GenServer, :call, _info}} before re-exiting in the caller, so the previous flat catch patterns missed reasons like {:shutdown, term} and recorded the wrapped tuple as the EnactmentCallFailed reason. Extract a small classify_exit/3 helper that unwraps once and dispatches by the underlying reason. Tests cover: - start_workitem / complete_workitem / Enactment.Supervisor.terminate_enactment against a never-started enactment_id (-> EnactmentNotRunning :not_started). - call_enactment/3 against stub processes that exit :normal, {:shutdown, _}, an arbitrary atom, or simply stall past the timeout. - Registry.whereis/1 lookup and miss paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3245b8b commit 2c26f77

3 files changed

Lines changed: 378 additions & 38 deletions

File tree

lib/coloured_flow/runner/enactment/workitem_transition.ex

Lines changed: 66 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -80,46 +80,74 @@ defmodule ColouredFlow.Runner.Enactment.WorkitemTransition do
8080
try do
8181
GenServer.call(pid, message, timeout)
8282
catch
83-
:exit, {:noproc, _info} ->
84-
{:error,
85-
Exceptions.EnactmentNotRunning.exception(
86-
enactment_id: enactment_id,
87-
reason: :not_started
88-
)}
89-
90-
:exit, {:timeout, _info} ->
91-
{:error,
92-
Exceptions.EnactmentTimeout.exception(
93-
enactment_id: enactment_id,
94-
timeout: timeout
95-
)}
96-
97-
:exit, {:shutdown, _info} ->
98-
{:error,
99-
Exceptions.EnactmentNotRunning.exception(
100-
enactment_id: enactment_id,
101-
reason: :shutting_down
102-
)}
103-
104-
:exit, {:normal, _info} ->
105-
{:error,
106-
Exceptions.EnactmentNotRunning.exception(
107-
enactment_id: enactment_id,
108-
reason: :stopped_during_call
109-
)}
110-
111-
:exit, {:calling_self, _info} = reason ->
112-
# Programming error — surface it.
113-
exit(reason)
114-
11583
:exit, reason ->
116-
# Catch-all: :killed, {:nodedown, _}, or any other crash mid-call.
117-
{:error,
118-
Exceptions.EnactmentCallFailed.exception(
119-
enactment_id: enactment_id,
120-
reason: reason
121-
)}
84+
classify_exit(unwrap_call_reason(reason), enactment_id, timeout)
12285
end
12386
end
12487
end
88+
89+
# `GenServer.call/3` wraps the callee's exit reason as
90+
# `{reason, {GenServer, :call, [pid, message, timeout]}}` before re-exiting in
91+
# the caller. Unwrap so we can classify by the underlying reason alone.
92+
# credo:disable-for-next-line JetCredo.Checks.ExplicitAnyType
93+
@spec unwrap_call_reason(term()) :: term()
94+
defp unwrap_call_reason({reason, {GenServer, :call, _info}}), do: reason
95+
defp unwrap_call_reason(reason), do: reason
96+
97+
# credo:disable-for-next-line JetCredo.Checks.ExplicitAnyType
98+
@spec classify_exit(term(), enactment_id(), timeout()) ::
99+
{:error, Exception.t()} | no_return()
100+
defp classify_exit(:noproc, enactment_id, _timeout) do
101+
{:error,
102+
Exceptions.EnactmentNotRunning.exception(
103+
enactment_id: enactment_id,
104+
reason: :not_started
105+
)}
106+
end
107+
108+
defp classify_exit(:timeout, enactment_id, timeout) do
109+
{:error,
110+
Exceptions.EnactmentTimeout.exception(
111+
enactment_id: enactment_id,
112+
timeout: timeout
113+
)}
114+
end
115+
116+
defp classify_exit(:normal, enactment_id, _timeout) do
117+
{:error,
118+
Exceptions.EnactmentNotRunning.exception(
119+
enactment_id: enactment_id,
120+
reason: :stopped_during_call
121+
)}
122+
end
123+
124+
defp classify_exit(:shutdown, enactment_id, _timeout) do
125+
{:error,
126+
Exceptions.EnactmentNotRunning.exception(
127+
enactment_id: enactment_id,
128+
reason: :shutting_down
129+
)}
130+
end
131+
132+
defp classify_exit({:shutdown, _shutdown_reason}, enactment_id, _timeout) do
133+
{:error,
134+
Exceptions.EnactmentNotRunning.exception(
135+
enactment_id: enactment_id,
136+
reason: :shutting_down
137+
)}
138+
end
139+
140+
defp classify_exit(:calling_self, _enactment_id, _timeout) do
141+
# Programming error — surface it instead of swallowing.
142+
exit(:calling_self)
143+
end
144+
145+
defp classify_exit(reason, enactment_id, _timeout) do
146+
# Catch-all: `:killed`, `{:nodedown, node}`, or any other crash reason.
147+
{:error,
148+
Exceptions.EnactmentCallFailed.exception(
149+
enactment_id: enactment_id,
150+
reason: reason
151+
)}
152+
end
125153
end
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
defmodule ColouredFlow.Runner.Enactment.WorkitemTransitionTest do
2+
@moduledoc """
3+
Tests for the caller-safe wrapper around `GenServer.call/3` in
4+
`WorkitemTransition`. Covers the full `:exit` surface defined in
5+
`error_handling_design.md`.
6+
"""
7+
8+
use ExUnit.Case, async: true
9+
10+
alias ColouredFlow.Runner.Enactment.Registry, as: EnactmentRegistry
11+
alias ColouredFlow.Runner.Enactment.Supervisor, as: EnactmentSupervisor
12+
alias ColouredFlow.Runner.Enactment.WorkitemTransition
13+
alias ColouredFlow.Runner.Exceptions
14+
15+
describe "start_workitem/2 against a non-running enactment" do
16+
test "returns EnactmentNotRunning when the enactment was never started" do
17+
enactment_id = Ecto.UUID.generate()
18+
19+
assert {:error, %Exceptions.EnactmentNotRunning{} = ex} =
20+
WorkitemTransition.start_workitem(enactment_id, Ecto.UUID.generate())
21+
22+
assert ex.enactment_id == enactment_id
23+
assert ex.reason == :not_started
24+
assert ex.error_code == :enactment_not_running
25+
end
26+
end
27+
28+
describe "complete_workitem/2 against a non-running enactment" do
29+
test "returns EnactmentNotRunning when the enactment was never started" do
30+
enactment_id = Ecto.UUID.generate()
31+
32+
assert {:error, %Exceptions.EnactmentNotRunning{} = ex} =
33+
WorkitemTransition.complete_workitem(enactment_id, {Ecto.UUID.generate(), []})
34+
35+
assert ex.enactment_id == enactment_id
36+
assert ex.reason == :not_started
37+
end
38+
end
39+
40+
describe "Enactment.Supervisor.terminate_enactment/2 against a non-running enactment" do
41+
test "returns EnactmentNotRunning when the enactment was never started" do
42+
enactment_id = Ecto.UUID.generate()
43+
44+
assert {:error, %Exceptions.EnactmentNotRunning{} = ex} =
45+
EnactmentSupervisor.terminate_enactment(enactment_id)
46+
47+
assert ex.enactment_id == enactment_id
48+
assert ex.reason == :not_started
49+
end
50+
end
51+
52+
describe "call_enactment/3 :exit surface coverage" do
53+
test "EnactmentTimeout when the called process does not reply within the timeout" do
54+
enactment_id = Ecto.UUID.generate()
55+
{pid, ref} = spawn_registered_stub(enactment_id, fn -> :stall end)
56+
57+
try do
58+
assert {:error, %Exceptions.EnactmentTimeout{} = ex} =
59+
WorkitemTransition.call_enactment(enactment_id, :anything, 50)
60+
61+
assert ex.enactment_id == enactment_id
62+
assert ex.timeout == 50
63+
after
64+
send(pid, :stop)
65+
await_down(pid, ref)
66+
end
67+
end
68+
69+
test "EnactmentNotRunning(:stopped_during_call) when called process exits :normal mid-call" do
70+
enactment_id = Ecto.UUID.generate()
71+
{_pid, ref} = spawn_registered_stub(enactment_id, fn -> {:exit_on_call, :normal} end)
72+
73+
assert {:error, %Exceptions.EnactmentNotRunning{} = ex} =
74+
WorkitemTransition.call_enactment(enactment_id, :anything, 1_000)
75+
76+
assert ex.enactment_id == enactment_id
77+
assert ex.reason == :stopped_during_call
78+
79+
await_down(:_pid, ref)
80+
end
81+
82+
test "EnactmentNotRunning(:shutting_down) when called process exits {:shutdown, _} mid-call" do
83+
enactment_id = Ecto.UUID.generate()
84+
85+
{_pid, ref} =
86+
spawn_registered_stub(enactment_id, fn -> {:exit_on_call, {:shutdown, :test}} end)
87+
88+
assert {:error, %Exceptions.EnactmentNotRunning{} = ex} =
89+
WorkitemTransition.call_enactment(enactment_id, :anything, 1_000)
90+
91+
assert ex.enactment_id == enactment_id
92+
assert ex.reason == :shutting_down
93+
94+
await_down(:_pid, ref)
95+
end
96+
97+
test "EnactmentCallFailed catch-all when called process exits with arbitrary reason" do
98+
enactment_id = Ecto.UUID.generate()
99+
100+
{_pid, ref} =
101+
spawn_registered_stub(enactment_id, fn -> {:exit_on_call, :custom_crash_reason} end)
102+
103+
assert {:error, %Exceptions.EnactmentCallFailed{} = ex} =
104+
WorkitemTransition.call_enactment(enactment_id, :anything, 1_000)
105+
106+
assert ex.enactment_id == enactment_id
107+
assert ex.reason == :custom_crash_reason
108+
assert ex.error_code == :enactment_call_failed
109+
110+
await_down(:_pid, ref)
111+
end
112+
end
113+
114+
describe "Registry.whereis/1" do
115+
test "returns :error when no process is registered for the key" do
116+
assert :error == EnactmentRegistry.whereis({:enactment, Ecto.UUID.generate()})
117+
end
118+
119+
test "returns {:ok, pid} when a process is registered" do
120+
enactment_id = Ecto.UUID.generate()
121+
{pid, ref} = spawn_registered_stub(enactment_id, fn -> :stall end)
122+
123+
try do
124+
assert {:ok, ^pid} = EnactmentRegistry.whereis({:enactment, enactment_id})
125+
after
126+
send(pid, :stop)
127+
await_down(pid, ref)
128+
end
129+
end
130+
end
131+
132+
defp spawn_registered_stub(enactment_id, behaviour_fun) do
133+
parent = self()
134+
135+
pid =
136+
spawn(fn ->
137+
{:ok, _owner} =
138+
Registry.register(EnactmentRegistry, {:enactment, enactment_id}, nil)
139+
140+
send(parent, {:registered, self()})
141+
142+
case behaviour_fun.() do
143+
:stall ->
144+
receive do
145+
:stop -> :ok
146+
end
147+
148+
{:exit_on_call, exit_reason} ->
149+
receive do
150+
{:"$gen_call", _from, _msg} -> exit(exit_reason)
151+
end
152+
end
153+
end)
154+
155+
ref = Process.monitor(pid)
156+
157+
receive do
158+
{:registered, ^pid} -> :ok
159+
after
160+
500 -> raise "stub registration timed out"
161+
end
162+
163+
{pid, ref}
164+
end
165+
166+
defp await_down(_pid, ref) do
167+
receive do
168+
{:DOWN, ^ref, :process, _object, _reason} -> :ok
169+
after
170+
1_000 -> :ok
171+
end
172+
end
173+
end

0 commit comments

Comments
 (0)