Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
2 changes: 1 addition & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ❌ | |
Expand Down
2 changes: 2 additions & 0 deletions docs/KNOWN_LIMITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 35 additions & 7 deletions erlang/stdlib/proc_lib.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
125 changes: 125 additions & 0 deletions tests/proc_lib_init_crash.erl
Original file line number Diff line number Diff line change
@@ -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.
Loading