diff --git a/CHANGELOG.md b/CHANGELOG.md index 2553b6c..90d521e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,20 @@ All notable milestones for the nera project are documented in this file. neither location is safe. Existing paths are never repaired or removed. - Relative test fixtures still resolve from the project working directory. +## proc_lib start_link reports crashes before acknowledgement + +- `proc_lib:start_link/3,5` now monitors the child from its creation. If the + child dies before `init_ack`, a caller trapping exits receives + `{error, Reason}` instead of waiting forever; a non-trapping linked caller + exits with that reason, as on BEAM. +- Error reasons keep their stacktrace, and uncaught throws use + `{{nocatch, Value}, Stacktrace}`. Startup consumes its own exit/monitor + messages without removing unrelated messages or the link after a successful + acknowledgement. +- The `proc_lib_init_crash` fixture checks both arities, error/exit/throw, + normal termination before acknowledgement and both `init_ack` forms + natively and on BEAM. Finite timeouts and spawn options remain unsupported. + ## Stacktraces list the raising function first - Captured exception stacktraces now list the raising function before its diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index a77708b..677c172 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -48,6 +48,33 @@ a dependency, or using the editor tools does not establish runtime compatibility Dated Thoas sections below preserve historical evidence; the table above and the integration README summary define the current verdict. +## proc_lib startup failure regression + +On macOS arm64 with Rust 1.99.0 and Erlang/OTP 29.1.2, the same +`tests/proc_lib_init_crash.erl` fixture passes natively and on BEAM. +Before the fix, main `036272267d8b578c1354f178f395f29f907676f2` fails the +first trapping-caller start with `start_link_still_waiting` after five seconds. +With the fix, both `proc_lib:start_link/3,5` return `{error, Reason}` to +trapping callers and exit non-trapping linked callers with BEAM's reason for +an error, exit or uncaught throw before acknowledgement. The fixture also +checks normal pre-ack termination, mailbox cleanup, both `init_ack` forms, +metadata and preserved post-ack links. + +Reproduce with a freshly built runtime and compiler: + +```sh +./target/release/nera test proc_lib_init_crash -j 1 --timeout 30 +beam_dir=$(mktemp -d) +erlc -o "$beam_dir" tests/proc_lib_init_crash.erl +erl -noshell -pa "$beam_dir" -eval 'ok = proc_lib_init_crash:test(), halt().' +rm -rf "$beam_dir" +``` + +The fixture must reach `proc_lib_init_crash_ok`; the native test runner checks +this completion marker, and BEAM prints it. This is a scoped regression, not a successful +Ranch or Cowboy startup: neither integration was rerun for this change. +`start_link/5` still ignores finite timeouts and spawn options. + ## Cowboy analysis — 2026-10-20 Cowboy **2.20.0** (`0a51d96698e1496720f9e13b4c1b91106dcc8a49`) diff --git a/FEATURES.md b/FEATURES.md index 6a276e9..6a65c85 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -691,7 +691,7 @@ optimization as counters — on par with BEAM performance. | gen_server | ✅ | start_link, call, cast, info, reply; `{error, Reason}` when init/1 fails, `ignore` when it ignores; stops with its parent when it traps exits | | supervisor (one_for_one) | ✅ | start_link, child specs, restart logic with retries; OTP start results: `{error, Reason}` when init/1 fails, `failed_to_start_child`, `ignore`; stops with its parent and stops its children in reverse start order by their shutdown setting | | application | ✅ | start/stop, app_name → app_name_app | -| proc_lib | ✅ | spawn, spawn_link, init_ack | +| proc_lib | ✅ | spawn, spawn_link, init_ack; start_link/3,5 returns `{error, Reason}` to trapping callers when the child dies before acknowledgement; finite timeouts and spawn options ignored | | error_logger | ✅ | info/error/warning with colored output | | gen_event | ❌ | | | gen_statem | ❌ | | diff --git a/docs/KNOWN_LIMITS.md b/docs/KNOWN_LIMITS.md index 59154a1..0a13f5c 100644 --- a/docs/KNOWN_LIMITS.md +++ b/docs/KNOWN_LIMITS.md @@ -72,6 +72,8 @@ platform and scope. `function_clause`, and map-form child specs are refused with `{start_spec, {invalid_child_spec, Spec}}`. `start_child/2`, `terminate_child/2` and `which_children/1` are missing. +- **`proc_lib`:** `start_link/3,5` reports a child dying before `init_ack`, + but `start_link/5` still ignores its timeout and spawn-option arguments. - **`application`:** `get_env/1` is missing. - **ETS:** `ets:match/2` and `ets:select/2` are missing. `ets:new/2` without `named_table` returns an integer where BEAM returns a reference. diff --git a/erlang/stdlib/proc_lib.erl b/erlang/stdlib/proc_lib.erl index 96d5860..9da37bb 100644 --- a/erlang/stdlib/proc_lib.erl +++ b/erlang/stdlib/proc_lib.erl @@ -87,14 +87,42 @@ init_ack(Parent, Return) -> Parent ! {init_ack, self(), Return}, ok. -%% start_link/3 — synchronous start (like gen_server start_link but lower-level). -%% Calls Mod:init(Args), waits for init_ack. +%% start_link/3 — apply Mod:Fun(Args), then wait for init_ack or child exit. start_link(Mod, Fun, Args) -> - Pid = spawn_link(Mod, Fun, Args), + start_link(Mod, Fun, Args, infinity, []). + +%% start_link/5 — timeout and spawn options are not yet supported. +start_link(Mod, Fun, Args, _Timeout, _Opts) -> + Parent = self(), + %% Monitor from creation: the child can die before the caller runs again. + {Pid, MRef} = erlang:spawn_opt(fun() -> + start_init(Parent, Mod, Fun, Args) + end, [link, monitor]), receive - {init_ack, Pid, Return} -> Return + {init_ack, Pid, Return} -> + erlang:demonitor(MRef), + receive {'DOWN', MRef, process, Pid, _} -> ok after 0 -> ok end, + Return; + {'DOWN', MRef, process, Pid, Reason} -> + %% The link's EXIT precedes DOWN. Consume only this child's exit, + %% as OTP does, leaving unrelated messages untouched. + erlang:unlink(Pid), + receive {'EXIT', Pid, _} -> ok after 0 -> ok end, + {error, Reason} end. -%% start_link/5 — start_link with name registration. -start_link(Mod, Fun, Args, _Timeout, _Opts) -> - start_link(Mod, Fun, Args). +start_init(Parent, Mod, Fun, Args) -> + put('$ancestors', [Parent]), + put('$initial_call', {Mod, Fun, length(Args)}), + try apply(Mod, Fun, Args) + catch + Class:Reason:Stacktrace -> + error_logger:error_msg("Error in process ~p (~p:~p/~p): ~p:~p~n", + [self(), Mod, Fun, length(Args), Class, Reason]), + %% Preserve OTP's exit envelope, not just the caught reason. + case Class of + error -> erlang:exit({Reason, Stacktrace}); + throw -> erlang:exit({{nocatch, Reason}, Stacktrace}); + exit -> erlang:exit(Reason) + end + end. diff --git a/tests/README.md b/tests/README.md index a411685..99cbf6d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -445,6 +445,7 @@ These are automatically skipped by the runner and show up as SKIP in output. |--------------------------|----------------------------------------|------------| | `gen_server_basic.erl` | gen_server start_link/call/cast/info | ~12 | | `supervisor_basic.erl` | Supervisor one_for_one restart | ~8 | +| `proc_lib_init_crash.erl` | proc_lib:start_link/3,5: error/exit/throw before init_ack returns BEAM's `{error, Reason}` to trapping callers and exits non-trapping linked callers; normal pre-ack termination, no startup EXIT/DOWN residue, unrelated messages preserved, both init_ack forms, metadata and post-ack links; same fixture on BEAM | Table-driven | | `gen_server_init_crash.erl` | gen_server:start_link/3,4, start/3 and supervisor:start_link/2,3 when init/1 raises, exits, throws, returns `{error, Reason}` or a bad value, when supervisor flags are invalid and when the name is taken: `{error, Reason}` for a caller trapping exits, no leftover `'EXIT'`/`'DOWN'`, name freed, a linked caller that does not trap exits exits with Reason; init/1 returning or throwing `ignore` gives `ignore`; also run on BEAM | 64 | | `supervisor_child_start.erl` | supervisor:start_link/2,3 when a child's start returns `{error, Reason}`, a value that is not a start result, raises, exits, throws or is a gen_server whose init/1 crashes: `{error, {shutdown, {failed_to_start_child, Id, Reason}}}` with OTP's Reason, later children not started, earlier ones stopped, name freed, no leftover `'EXIT'`/`'DOWN'`, a linked caller that does not trap exits exits with it; a child returning or throwing `ignore` and a child supervisor returning `ignore` do not fail the start, and the children after them start; a failed restart tried again and counted against the restart limit, then `shutdown`; a restart returning `ignore` keeps the supervisor running; also run on BEAM | 48 | | `supervisor_shutdown.erl` | How a one_for_one supervisor stops: when its parent exits (normally or not) or sends it `shutdown`, with the parent's reason, while another process's exit signal is ignored; before it exits, after its parent stops it, past its restart limit or when a child fails to start, it stops its running children one by one, the last started first, by their shutdown setting (`brutal_kill`, a timeout followed by a kill for a child that traps exits and ignores them, `infinity` for a child supervisor, which stops its own children), skipping a child that is not running and not waiting for one that already exited; an invalid child spec gives `{start_spec, Error}` before any child starts; a `gen_server` that traps exits runs `terminate/2`, which is optional, and stops with its parent's reason, while another process's exit signal, or its starter's when started without a link, reaches `handle_info/2`; helper module `_supervisor_shutdown_server.erl`; also run on BEAM | 76 | diff --git a/tests/proc_lib_init_crash.erl b/tests/proc_lib_init_crash.erl new file mode 100644 index 0000000..7bc2b4e --- /dev/null +++ b/tests/proc_lib_init_crash.erl @@ -0,0 +1,125 @@ +%% EXPECT: proc_lib_init_crash_ok +%% Intent: proc_lib:start_link/3,5 must not hang when the child dies before +%% init_ack. Trapping callers return {error, Reason}; non-trapping linked +%% callers exit with Reason, including BEAM's stack-bearing error/throw reasons. +%% Run this same real-process regression natively and on BEAM. +-module(proc_lib_init_crash). +-export([test/0, init/2]). + +test() -> + lists:foreach(fun(Arity) -> + lists:foreach(fun(Mode) -> + {returned, {error, Reason}} = in_caller(true, fun() -> + %% Startup must preserve unrelated messages in the mailbox. + self() ! unrelated, + Result = start(Arity, Mode), + receive unrelated -> ok after 0 -> error(lost_message) end, + no_messages(), + Result + end), + ok = check_reason(Mode, Reason), + io:format("start_link/~p trap ~p: returned expected error~n", + [Arity, Mode]), + {exited, ExitReason} = in_caller(false, fun() -> start(Arity, Mode) end), + ok = check_reason(Mode, ExitReason), + io:format("start_link/~p plain ~p: exited with expected reason~n", + [Arity, Mode]) + end, [error, exit, throw]), + + %% A normal child exit before acknowledgement must not hang either. + lists:foreach(fun(Trap) -> + {returned, {error, normal}} = in_caller(Trap, fun() -> + Result = start(Arity, normal), + no_messages(), + Result + end) + end, [true, false]), + io:format("start_link/~p normal: returned error without hanging~n", [Arity]), + + lists:foreach(fun(Ack) -> + {returned, ok} = in_caller(true, fun() -> + {ok, Pid} = start(Arity, {ack, Ack}), + no_messages(), + true = is_process_alive(Pid), + Pid ! {stop, after_ack}, + receive {'EXIT', Pid, after_ack} -> ok + after 5000 -> error(missing_link_after_ack) + end, + no_messages() + end), + %% An ack followed immediately by exit still returns the ack, + %% retains the link's EXIT and leaves no internal DOWN behind. + {returned, ok} = in_caller(true, fun() -> + {ok, Pid} = start(Arity, {ack_exit, Ack}), + receive {'EXIT', Pid, normal} -> ok + after 5000 -> error(missing_exit_after_ack) + end, + no_messages() + end), + {exited, after_ack} = in_caller(false, fun() -> + {ok, Pid} = start(Arity, {ack, Ack}), + Pid ! {stop, after_ack}, + receive unexpected -> error(unexpected) end + end), + io:format("start_link/~p init_ack/~p: acknowledged, link preserved~n", + [Arity, Ack]) + end, [1, 2]) + end, [3, 5]), + io:format("proc_lib_init_crash_ok~n"), + ok. + +start(3, Mode) -> proc_lib:start_link(?MODULE, init, [self(), Mode]); +start(5, Mode) -> proc_lib:start_link(?MODULE, init, [self(), Mode], infinity, []). + +init(_Parent, error) -> 1 = id(2); +init(_Parent, exit) -> exit(boom); +init(_Parent, throw) -> throw(uncaught); +init(_Parent, normal) -> ok; +init(Parent, {ack_exit, Ack}) -> acknowledge(Parent, Ack); +init(Parent, {ack, Ack}) -> + acknowledge(Parent, Ack), + receive {stop, Reason} -> exit(Reason) end. + +acknowledge(Parent, Ack) -> + [Parent | _] = get('$ancestors'), + {?MODULE, init, 2} = get('$initial_call'), + case Ack of + 1 -> proc_lib:init_ack({ok, self()}); + 2 -> proc_lib:init_ack(Parent, {ok, self()}) + end. + +check_reason(error, {{badmatch, 2}, Stack}) -> check_stack(Stack); +check_reason(exit, boom) -> ok; +check_reason(throw, {{nocatch, uncaught}, Stack}) -> check_stack(Stack). + +%% Check the callback frame, not engine-dependent callers or source locations. +check_stack([{?MODULE, init, 2, _} | _]) -> ok. + +%% Wait for the caller's real exit before judging whether start_link returned. +%% The outer bound turns the original infinite wait into a test failure. +in_caller(Trap, Fun) -> + Parent = self(), + {Pid, Ref} = spawn_monitor(fun() -> + process_flag(trap_exit, Trap), + Result = Fun(), + Parent ! {returned, self(), Result} + end), + receive + {'DOWN', Ref, process, Pid, Reason} -> + receive + {returned, Pid, Result} -> + normal = Reason, + {returned, Result} + after 0 -> {exited, Reason} + end + after 5000 -> + exit(Pid, kill), + error(start_link_still_waiting) + end. + +no_messages() -> + receive Msg -> error({unexpected_message, Msg}) + after 0 -> ok + end. + +id(X) -> X.