Current section

Files

Jump to
dream_test src dream_test@sandbox.erl
Raw

src/dream_test@sandbox.erl

-module(dream_test@sandbox).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/dream_test/sandbox.gleam").
-export([run_isolated/2, default_config/0, with_crash_reports/1]).
-export_type([sandbox_config/0, sandbox_result/1, worker_message/1]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Process isolation for test execution (sandboxing).\n"
"\n"
" This module provides the core mechanism for running a function in an\n"
" isolated BEAM process with timeout support. It is used internally by the\n"
" runner and is also useful directly in tests that need strong crash/timeout\n"
" boundaries.\n"
"\n"
" ## What problem does this solve?\n"
"\n"
" Sometimes you want to run code that might:\n"
"\n"
" - `panic`\n"
" - hang (never return)\n"
" - take “too long” and should be timed out\n"
"\n"
" `run_isolated` runs the function in a separate BEAM process, so the caller\n"
" stays healthy even if the function crashes.\n"
"\n"
" ## Selective crash reports\n"
"\n"
" When a test process crashes (e.g. `panic as \"boom\"`), Erlang can print a\n"
" crash report (`=CRASH REPORT==== ...`). This is useful when debugging, but\n"
" noisy during normal runs.\n"
"\n"
" `SandboxConfig.show_crash_reports` lets you choose:\n"
"\n"
" - `False` (default): suppress crash reports and return `SandboxCrashed(...)`\n"
" - `True`: allow crash reports to print, and still return `SandboxCrashed(...)`\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" import dream_test/matchers.{be_equal, or_fail_with, should}\n"
" import dream_test/sandbox.{\n"
" SandboxCompleted, SandboxConfig, SandboxCrashed, SandboxTimedOut,\n"
" }\n"
" import dream_test/unit.{describe, it}\n"
"\n"
" fn loop_forever() {\n"
" loop_forever()\n"
" }\n"
"\n"
" pub fn tests() {\n"
" describe(\"Sandboxing\", [\n"
" it(\"run_isolated returns SandboxCompleted(value) on success\", fn() {\n"
" let config = SandboxConfig(timeout_ms: 100, show_crash_reports: False)\n"
" let result = sandbox.run_isolated(config, fn() { 123 })\n"
"\n"
" result\n"
" |> should\n"
" |> be_equal(SandboxCompleted(123))\n"
" |> or_fail_with(\"expected SandboxCompleted(123)\")\n"
" }),\n"
"\n"
" it(\n"
" \"run_isolated returns SandboxTimedOut when the function is too slow\",\n"
" fn() {\n"
" let config = SandboxConfig(timeout_ms: 10, show_crash_reports: False)\n"
" let result = sandbox.run_isolated(config, loop_forever)\n"
"\n"
" result\n"
" |> should\n"
" |> be_equal(SandboxTimedOut)\n"
" |> or_fail_with(\"expected SandboxTimedOut\")\n"
" },\n"
" ),\n"
"\n"
" it(\"run_isolated returns SandboxCrashed when the function panics\", fn() {\n"
" let config = SandboxConfig(timeout_ms: 100, show_crash_reports: False)\n"
" let result = sandbox.run_isolated(config, fn() { panic as \"boom\" })\n"
"\n"
" let did_crash = case result {\n"
" SandboxCrashed(_) -> True\n"
" _ -> False\n"
" }\n"
"\n"
" did_crash\n"
" |> should\n"
" |> be_equal(True)\n"
" |> or_fail_with(\"expected SandboxCrashed(...)\")\n"
" }),\n"
" ])\n"
" }\n"
" ```\n"
).
-type sandbox_config() :: {sandbox_config, integer(), boolean()}.
-type sandbox_result(HZK) :: {sandbox_completed, HZK} |
sandbox_timed_out |
{sandbox_crashed, binary()}.
-type worker_message(HZL) :: {test_completed, HZL} | {worker_down, binary()}.
-file("src/dream_test/sandbox.gleam", 230).
?DOC(" Spawn a worker process that runs the test and sends the result.\n").
-spec spawn_worker(
gleam@erlang@process:subject(worker_message(HZR)),
fun(() -> HZR),
boolean()
) -> gleam@erlang@process:pid_().
spawn_worker(Result_subject, Test_function, Show_crash_reports) ->
proc_lib:spawn(fun() -> case Show_crash_reports of
true ->
Result = Test_function(),
gleam@erlang@process:send(
Result_subject,
{test_completed, Result}
);
false ->
case sandbox_ffi:run_catching(Test_function) of
{ok, Result@1} ->
gleam@erlang@process:send(
Result_subject,
{test_completed, Result@1}
);
{error, Reason} ->
gleam@erlang@process:send(
Result_subject,
{worker_down, Reason}
)
end
end end).
-file("src/dream_test/sandbox.gleam", 277).
?DOC(" Format an exit reason as a string for error reporting.\n").
-spec format_exit_reason(gleam@erlang@process:exit_reason()) -> binary().
format_exit_reason(Reason) ->
case Reason of
normal ->
<<"normal"/utf8>>;
killed ->
<<"killed"/utf8>>;
{abnormal, Dynamic_reason} ->
gleam@string:inspect(Dynamic_reason)
end.
-file("src/dream_test/sandbox.gleam", 271).
?DOC(" Map a process down event to a WorkerMessage.\n").
-spec map_down_to_message(gleam@erlang@process:down()) -> worker_message(any()).
map_down_to_message(Down) ->
Reason = format_exit_reason(erlang:element(4, Down)),
{worker_down, Reason}.
-file("src/dream_test/sandbox.gleam", 252).
?DOC(" Build a selector that waits for either test completion or process down.\n").
-spec build_result_selector(
gleam@erlang@process:subject(worker_message(HZU)),
boolean()
) -> gleam@erlang@process:selector(worker_message(HZU)).
build_result_selector(Result_subject, Show_crash_reports) ->
case Show_crash_reports of
true ->
_pipe = gleam_erlang_ffi:new_selector(),
_pipe@1 = gleam@erlang@process:select(_pipe, Result_subject),
gleam@erlang@process:select_monitors(
_pipe@1,
fun map_down_to_message/1
);
false ->
_pipe@2 = gleam_erlang_ffi:new_selector(),
gleam@erlang@process:select(_pipe@2, Result_subject)
end.
-file("src/dream_test/sandbox.gleam", 299).
?DOC(" Handle a message from the worker or monitor.\n").
-spec handle_worker_message(worker_message(IAF)) -> sandbox_result(IAF).
handle_worker_message(Message) ->
case Message of
{test_completed, Result} ->
{sandbox_completed, Result};
{worker_down, Reason} ->
{sandbox_crashed, Reason}
end.
-file("src/dream_test/sandbox.gleam", 307).
?DOC(" Handle timeout by killing the worker and returning timeout result.\n").
-spec handle_timeout(gleam@erlang@process:pid_()) -> sandbox_result(any()).
handle_timeout(Worker_pid) ->
gleam@erlang@process:kill(Worker_pid),
sandbox_timed_out.
-file("src/dream_test/sandbox.gleam", 286).
?DOC(" Wait for the worker to complete or timeout.\n").
-spec wait_for_result(
sandbox_config(),
gleam@erlang@process:selector(worker_message(IAB)),
gleam@erlang@process:pid_(),
gleam@erlang@process:monitor()
) -> sandbox_result(IAB).
wait_for_result(Config, Selector, Worker_pid, _) ->
case gleam_erlang_ffi:select(Selector, erlang:element(2, Config)) of
{ok, Message} ->
handle_worker_message(Message);
{error, nil} ->
handle_timeout(Worker_pid)
end.
-file("src/dream_test/sandbox.gleam", 215).
?DOC(
" Run a test function in an isolated process with timeout.\n"
"\n"
" The test function runs in a separate BEAM process that is monitored.\n"
" If the process completes normally, its result is returned.\n"
" If the process crashes or times out, an appropriate SandboxResult is returned.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" import dream_test/matchers.{be_equal, or_fail_with, should}\n"
" import dream_test/sandbox.{\n"
" SandboxCompleted, SandboxConfig, SandboxCrashed, SandboxTimedOut,\n"
" }\n"
" import dream_test/unit.{describe, it}\n"
"\n"
" fn loop_forever() {\n"
" loop_forever()\n"
" }\n"
"\n"
" pub fn tests() {\n"
" describe(\"Sandboxing\", [\n"
" it(\"run_isolated returns SandboxCompleted(value) on success\", fn() {\n"
" let config = SandboxConfig(timeout_ms: 100, show_crash_reports: False)\n"
" let result = sandbox.run_isolated(config, fn() { 123 })\n"
"\n"
" result\n"
" |> should\n"
" |> be_equal(SandboxCompleted(123))\n"
" |> or_fail_with(\"expected SandboxCompleted(123)\")\n"
" }),\n"
"\n"
" it(\n"
" \"run_isolated returns SandboxTimedOut when the function is too slow\",\n"
" fn() {\n"
" let config = SandboxConfig(timeout_ms: 10, show_crash_reports: False)\n"
" let result = sandbox.run_isolated(config, loop_forever)\n"
"\n"
" result\n"
" |> should\n"
" |> be_equal(SandboxTimedOut)\n"
" |> or_fail_with(\"expected SandboxTimedOut\")\n"
" },\n"
" ),\n"
"\n"
" it(\"run_isolated returns SandboxCrashed when the function panics\", fn() {\n"
" let config = SandboxConfig(timeout_ms: 100, show_crash_reports: False)\n"
" let result = sandbox.run_isolated(config, fn() { panic as \"boom\" })\n"
"\n"
" let did_crash = case result {\n"
" SandboxCrashed(_) -> True\n"
" _ -> False\n"
" }\n"
"\n"
" did_crash\n"
" |> should\n"
" |> be_equal(True)\n"
" |> or_fail_with(\"expected SandboxCrashed(...)\")\n"
" }),\n"
" ])\n"
" }\n"
" ```\n"
"\n"
" ## Parameters\n"
"\n"
" - `config`: sandbox configuration (timeout + crash report behavior)\n"
" - `test_function`: function to run in an isolated process\n"
"\n"
" ## Returns\n"
"\n"
" A `SandboxResult(a)` describing completion, timeout, or crash.\n"
).
-spec run_isolated(sandbox_config(), fun(() -> HZP)) -> sandbox_result(HZP).
run_isolated(Config, Test_function) ->
Result_subject = gleam@erlang@process:new_subject(),
Worker_pid = spawn_worker(
Result_subject,
Test_function,
erlang:element(3, Config)
),
Worker_monitor = gleam@erlang@process:monitor(Worker_pid),
Selector = build_result_selector(Result_subject, erlang:element(3, Config)),
wait_for_result(Config, Selector, Worker_pid, Worker_monitor).
-file("src/dream_test/sandbox.gleam", 323).
?DOC(
" Default configuration with a 5 second timeout.\n"
"\n"
" Uses `show_crash_reports: False`.\n"
"\n"
" ## Returns\n"
"\n"
" A `SandboxConfig` suitable for most tests.\n"
"\n"
" ## Parameters\n"
"\n"
" None.\n"
).
-spec default_config() -> sandbox_config().
default_config() ->
{sandbox_config, 5000, false}.
-file("src/dream_test/sandbox.gleam", 344).
?DOC(
" Convenience helper for enabling crash reports in the sandbox.\n"
"\n"
" This is useful when debugging failures locally.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let config = sandbox.default_config() |> sandbox.with_crash_reports()\n"
" ```\n"
"\n"
" ## Parameters\n"
"\n"
" - `config`: an existing `SandboxConfig`\n"
"\n"
" ## Returns\n"
"\n"
" A new `SandboxConfig` with `show_crash_reports: True`.\n"
).
-spec with_crash_reports(sandbox_config()) -> sandbox_config().
with_crash_reports(Config) ->
{sandbox_config, erlang:element(2, Config), true}.