From cd8cac1408e91e41e63b54a9632ea907c725f30f Mon Sep 17 00:00:00 2001 From: Paul Guyot Date: Mon, 3 Aug 2026 13:48:22 +0200 Subject: [PATCH] estdlib: add gen:start/5,6 The Elixir GenServer implementation calls them. The monitor linkage folds proc_lib:start_monitor/3's {Result, MonitorRef} into OTP's {ok, {Pid, Mon}}. A named start no longer spawns a child when the name is taken, and a failed registration terminates the child through init_fail so no EXIT or DOWN is left in the caller's mailbox. init/1 returning ignore now yields ignore, and the timeout and spawn_opt options are honoured. Forwarding spawn_opt made proc_lib's failure cleanup reachable with a link the caller did not ask for, so it now unlinks unconditionally like OTP instead of tracking whether it created the link. Signed-off-by: Paul Guyot --- CHANGELOG.md | 7 ++ libs/estdlib/src/gen.erl | 62 ++++++++++++++ libs/estdlib/src/gen_server.erl | 16 ++-- libs/estdlib/src/proc_lib.erl | 48 ++++------- tests/libs/estdlib/test_gen_server.erl | 113 +++++++++++++++++++++++++ tests/libs/estdlib/test_proc_lib.erl | 9 ++ 6 files changed, 220 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 120e307cbd..c3ec91adc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `onRunTrackedJs`, `onGetTrackedObjects` and `onTrackedObjectDelete` hooks, which embedders may override to customize what tracking means - Added `string:to_integer/1` +- Added `gen:start/5,6`, used by Elixir's `GenServer` ### Changed - `erlang:process_info/2` now accepts only pids of local processes, as Erlang/OTP does: @@ -111,6 +112,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 builds, which made `erlang:crc32/2`, `erlang:crc32_combine/3` and `crypto:pbkdf2_hmac/5` silently truncate huge integer arguments instead of raising `badarg` - Fixed a bug where bigints were not normalized, yielding equality errors +- Fixed `gen_server` returning an error instead of `ignore` when `init/1` returns `ignore` +- Fixed a failed named `gen_server` start leaving a stray `EXIT` or `DOWN` in the caller's mailbox +- Fixed `proc_lib:start/5` killing a linked caller when a start passing `{spawn_opt, [link]}` + timed out, and `proc_lib:start_link/5,start_monitor/5` hanging when the child died before + acknowledging +- Fixed `proc_lib:start*/5` accepting the `{monitor, _}` spawn option, which is not allowed ## [0.7.0-alpha.1] - 2026-04-06 diff --git a/libs/estdlib/src/gen.erl b/libs/estdlib/src/gen.erl index ccb9a233df..34d2eb3776 100644 --- a/libs/estdlib/src/gen.erl +++ b/libs/estdlib/src/gen.erl @@ -28,6 +28,8 @@ -moduledoc false. -export([ + start/5, + start/6, call/4, cast/2, reply/2 @@ -35,6 +37,66 @@ -type server_ref() :: atom() | pid(). -type from() :: {pid(), reference()}. +-type linkage() :: link | nolink | monitor. +-type emgr_name() :: {local, atom()}. + +%%----------------------------------------------------------------------------- +%% @doc Start a `gen_*' process without a registered name. This is the +%% OTP-private entry point Elixir's `GenServer.start_link/start' calls +%% (`:gen.start(:gen_server, Link, Module, Args, Options)'). It bridges to the +%% AtomVM `gen_*' module's `init_it' protocol via {@link proc_lib}. +%% @end +%%----------------------------------------------------------------------------- +-spec start( + GenMod :: module(), + LinkP :: linkage(), + Module :: module(), + Args :: term(), + Options :: list() +) -> {ok, pid()} | {ok, {pid(), reference()}} | ignore | {error, term()}. +start(GenMod, LinkP, Module, Args, Options) -> + do_start(GenMod, LinkP, [self(), Module, Args, Options], Options). + +%%----------------------------------------------------------------------------- +%% @doc Start a `gen_*' process with a registered name, as called by Elixir +%% (`:gen.start(:gen_server, Link, {local, Name}, Module, Args, Options)'). +%% +%% Only `{local, Name}' is supported. +%% @end +%%----------------------------------------------------------------------------- +-spec start( + GenMod :: module(), + LinkP :: linkage(), + Name :: emgr_name(), + Module :: module(), + Args :: term(), + Options :: list() +) -> {ok, pid()} | {ok, {pid(), reference()}} | ignore | {error, term()}. +start(GenMod, LinkP, {local, Name}, Module, Args, Options) when is_atom(Name) -> + case whereis(Name) of + undefined -> + do_start( + GenMod, LinkP, [self(), Name, Module, Args, [{name, Name} | Options]], Options + ); + Pid -> + {error, {already_started, Pid}} + end. + +%% @private +do_start(GenMod, LinkP, InitArgs, Options) -> + Timeout = proplists:get_value(timeout, Options, infinity), + SpawnOpts = proplists:get_value(spawn_opt, Options, []), + do_start(GenMod, LinkP, InitArgs, Timeout, SpawnOpts). + +do_start(GenMod, link, InitArgs, Timeout, SpawnOpts) -> + proc_lib:start_link(GenMod, init_it, InitArgs, Timeout, SpawnOpts); +do_start(GenMod, nolink, InitArgs, Timeout, SpawnOpts) -> + proc_lib:start(GenMod, init_it, InitArgs, Timeout, SpawnOpts); +do_start(GenMod, monitor, InitArgs, Timeout, SpawnOpts) -> + case proc_lib:start_monitor(GenMod, init_it, InitArgs, Timeout, SpawnOpts) of + {{ok, Pid}, Mon} -> {ok, {Pid, Mon}}; + {Error, _Mon} -> Error + end. %%----------------------------------------------------------------------------- %% @doc Perform a call on a gen server. This API not documented by OTP, diff --git a/libs/estdlib/src/gen_server.erl b/libs/estdlib/src/gen_server.erl index 25426972a6..9675385d52 100644 --- a/libs/estdlib/src/gen_server.erl +++ b/libs/estdlib/src/gen_server.erl @@ -68,15 +68,17 @@ }). -type options() :: list({atom(), term()}). --type start_ret() :: {ok, pid()} | {error, Reason :: term()}. --type start_mon_ret() :: {ok, {Pid :: pid(), MonRef :: reference()}} | {error, Reason :: term()}. +-type start_ret() :: {ok, pid()} | ignore | {error, Reason :: term()}. +-type start_mon_ret() :: + {ok, {Pid :: pid(), MonRef :: reference()}} | ignore | {error, Reason :: term()}. -type server_ref() :: atom() | pid(). -type from() :: {pid(), reference()}. -type init_result(StateType) :: {ok, State :: StateType} | {ok, State :: StateType, timeout() | {timeout, timeout(), Msg :: any()} | {continue, term()}} - | {stop, Reason :: any()}. + | {stop, Reason :: any()} + | ignore. -type handle_continue_result(StateType) :: {noreply, NewState :: StateType} @@ -137,9 +139,11 @@ init_it(Starter, Name, Module, Args, Options) -> badarg, S ), - proc_lib:init_ack(Starter, {error, badarg}); + proc_lib:init_fail(Starter, {error, badarg}, {exit, normal}); Pid when is_pid(Pid) -> - proc_lib:init_ack(Starter, {error, {already_started, Pid}}) + proc_lib:init_fail( + Starter, {error, {already_started, Pid}}, {exit, normal} + ) end end. @@ -169,6 +173,8 @@ init_it(Starter, Module, Args, Options) -> }}; {stop, Reason} -> {fail, {error, Reason}, {exit, Reason}}; + ignore -> + {fail, ignore, {exit, normal}}; Reply -> {fail, {error, {unexpected_reply_from_init, Reply}}, {exit, {bad_return_value, Reply}}} diff --git a/libs/estdlib/src/proc_lib.erl b/libs/estdlib/src/proc_lib.erl index 4a8bc6bac7..3d50fb08e3 100644 --- a/libs/estdlib/src/proc_lib.erl +++ b/libs/estdlib/src/proc_lib.erl @@ -140,7 +140,7 @@ start(Module, Function, Args, Timeout) -> %%----------------------------------------------------------------------------- -spec start(module(), atom(), [any()], timeout(), [start_spawn_option()]) -> any(). start(Module, Function, Args, Timeout, SpawnOpts) -> - start0(Module, Function, Args, Timeout, SpawnOpts, false, false). + start0(Module, Function, Args, Timeout, SpawnOpts, false). %% @equiv start_link(Module, Function, Args, infinity) -spec start_link(module(), atom(), [any()]) -> any(). @@ -164,7 +164,7 @@ start_link(Module, Function, Args, Timeout) -> %%----------------------------------------------------------------------------- -spec start_link(module(), atom(), [any()], timeout(), [start_spawn_option()]) -> any(). start_link(Module, Function, Args, Timeout, SpawnOpts) -> - start0(Module, Function, Args, Timeout, [link | SpawnOpts], true, false). + start0(Module, Function, Args, Timeout, [link | SpawnOpts], false). %% @equiv start_monitor(Module, Function, Args, infinity) -spec start_monitor(module(), atom(), [any()]) -> any(). @@ -188,11 +188,11 @@ start_monitor(Module, Function, Args, Timeout) -> %%----------------------------------------------------------------------------- -spec start_monitor(module(), atom(), [any()], timeout(), [start_spawn_option()]) -> any(). start_monitor(Module, Function, Args, Timeout, SpawnOpts) -> - start0(Module, Function, Args, Timeout, SpawnOpts, true, true). + start0(Module, Function, Args, Timeout, SpawnOpts, true). %% @private -start0(Module, Function, Args, Timeout, SpawnOpts, Link, Monitor) -> - case lists:member(monitor, SpawnOpts) of +start0(Module, Function, Args, Timeout, SpawnOpts, Monitor) -> + case lists:member(monitor, SpawnOpts) orelse lists:keymember(monitor, 1, SpawnOpts) of true -> error(badarg); false -> ok end, @@ -211,42 +211,27 @@ start0(Module, Function, Args, Timeout, SpawnOpts, Link, Monitor) -> %% so its registered name is freed and linked children have got their %% EXIT signal. {nack, Pid, Result} when Monitor -> + flush_exit(Pid), receive {'DOWN', MonitorRef, process, Pid, _} -> ok end, - flush_exit(Pid, Link), {Result, MonitorRef}; {nack, Pid, Result} -> + flush_exit(Pid), receive {'DOWN', MonitorRef, process, Pid, _} -> ok end, - flush_exit(Pid, Link), Result; - {'DOWN', MonitorRef, process, Pid, Reason} when Link -> - receive - {'EXIT', Pid, _} -> ok - after 0 -> ok - end, - receive - {'DOWN', MonitorRef, process, Pid, _} -> ok - end, - {error, Reason}; {'DOWN', MonitorRef, process, Pid, Reason} when Monitor -> + flush_exit(Pid), {{error, Reason}, MonitorRef}; {'DOWN', MonitorRef, process, Pid, Reason} -> + flush_exit(Pid), {error, Reason} after Timeout -> - if - Link -> - unlink(Pid), - exit(Pid, kill), - receive - {'EXIT', Pid, _} -> ok - after 0 -> ok - end; - true -> - exit(Pid, kill) - end, + unlink(Pid), + exit(Pid, kill), + flush_exit_message(Pid), receive {'DOWN', MonitorRef, process, Pid, _} -> ok end, @@ -259,9 +244,12 @@ start0(Module, Function, Args, Timeout, SpawnOpts, Link, Monitor) -> end. %% @private -flush_exit(_Pid, false) -> - ok; -flush_exit(Pid, true) -> +%% unlink as spawn_opt may have linked. +flush_exit(Pid) -> + unlink(Pid), + flush_exit_message(Pid). + +flush_exit_message(Pid) -> receive {'EXIT', Pid, _} -> ok after 0 -> ok diff --git a/tests/libs/estdlib/test_gen_server.erl b/tests/libs/estdlib/test_gen_server.erl index 8cf73e1af2..bd451c30fe 100644 --- a/tests/libs/estdlib/test_gen_server.erl +++ b/tests/libs/estdlib/test_gen_server.erl @@ -46,6 +46,10 @@ test() -> ok = test_start_link(), ok = test_start_monitor(), ok = test_start_name(), + ok = test_gen_start(), + ok = test_gen_start_named_occupied(), + ok = test_gen_start_ignore(), + ok = test_gen_start_timeout(), ok = test_continue(), ok = test_init_exception(), ok = test_late_reply(), @@ -133,6 +137,108 @@ test_start_name() -> undefined = whereis(?MODULE), ok. +%% gen:start/5,6 is the OTP-private entry point Elixir's GenServer.start* +%% functions call. +test_gen_start() -> + {ok, Pid1} = gen:start(gen_server, nolink, ?MODULE, [], []), + pong = gen_server:call(Pid1, ping), + ok = gen_server:stop(Pid1), + + PreviousTrapExit = erlang:process_flag(trap_exit, true), + {ok, Pid2} = gen:start(gen_server, link, ?MODULE, [], []), + pong = gen_server:call(Pid2, ping), + ok = gen_server:stop(Pid2), + normal = + receive + {'EXIT', Pid2, Reason} -> Reason + after 5000 -> timeout + end, + true = erlang:process_flag(trap_exit, PreviousTrapExit), + + {ok, {Pid3, Ref3}} = gen:start(gen_server, monitor, ?MODULE, [], []), + true = is_pid(Pid3), + true = is_reference(Ref3), + pong = gen_server:call(Pid3, ping), + ok = gen_server:cast(Pid3, crash), + ok = + receive + {'DOWN', Ref3, process, Pid3, _Reason} -> ok + after 30000 -> timeout + end, + + undefined = whereis(?MODULE), + {ok, Pid4} = gen:start(gen_server, nolink, {local, ?MODULE}, ?MODULE, [], []), + Pid4 = whereis(?MODULE), + {error, {already_started, Pid4}} = gen:start( + gen_server, nolink, {local, ?MODULE}, ?MODULE, [], [] + ), + ok = gen_server:stop(Pid4), + undefined = whereis(?MODULE), + ok. + +%% The caller's mailbox must be left empty: a failed start leaks no EXIT or DOWN. +test_gen_start_named_occupied() -> + {ok, Pid} = gen:start(gen_server, nolink, {local, ?MODULE}, ?MODULE, [], []), + PreviousTrapExit = erlang:process_flag(trap_exit, true), + {error, {already_started, Pid}} = gen:start( + gen_server, link, {local, ?MODULE}, ?MODULE, [], [] + ), + [] = drain_mailbox(), + {error, {already_started, Pid}} = gen:start( + gen_server, monitor, {local, ?MODULE}, ?MODULE, [], [] + ), + [] = drain_mailbox(), + ok = gen_server:stop(Pid), + _ = drain_mailbox(), + true = erlang:process_flag(trap_exit, PreviousTrapExit), + undefined = whereis(?MODULE), + ok. + +test_gen_start_ignore() -> + ignore = gen:start(gen_server, nolink, ?MODULE, ignore_me, []), + [] = drain_mailbox(), + ignore = gen:start(gen_server, nolink, {local, ?MODULE}, ?MODULE, ignore_me, []), + undefined = whereis(?MODULE), + [] = drain_mailbox(), + ignore = gen:start(gen_server, monitor, ?MODULE, ignore_me, []), + [] = drain_mailbox(), + PreviousTrapExit = erlang:process_flag(trap_exit, true), + ignore = gen:start(gen_server, link, ?MODULE, ignore_me, []), + [] = drain_mailbox(), + true = erlang:process_flag(trap_exit, PreviousTrapExit), + ok. + +%% Start isolated and non-trapping so a stray exit signal shows up as a kill. +test_gen_start_timeout() -> + {survived, {error, timeout}} = isolated_start(nolink, [{timeout, 200}]), + {survived, {error, timeout}} = isolated_start(link, [{timeout, 200}]), + {survived, {error, timeout}} = isolated_start(monitor, [{timeout, 200}]), + {survived, {error, timeout}} = isolated_start(nolink, [{timeout, 200}, {spawn_opt, [link]}]), + ok. + +isolated_start(LinkP, Options) -> + Self = self(), + Pid = spawn(fun() -> + Result = gen:start(gen_server, LinkP, ?MODULE, hang, Options), + Self ! {done, self(), Result} + end), + Ref = monitor(process, Pid), + receive + {done, Pid, Result} -> + demonitor(Ref, [flush]), + {survived, Result}; + {'DOWN', Ref, process, Pid, Reason} -> + {killed, Reason} + after 8000 -> + {no_reply, Pid} + end. + +drain_mailbox() -> + receive + M -> [M | drain_mailbox()] + after 100 -> [] + end. + test_continue() -> {ok, Pid} = gen_server:start_link(?MODULE, {continue, self()}, []), [{Pid, continue}, {Pid, after_continue}] = read_replies(Pid), @@ -585,6 +691,13 @@ get_otp_version() -> %% callbacks %% +init(ignore_me) -> + ignore; +init(hang) -> + receive + after 5000 -> ok + end, + {ok, #state{}}; init(throwme) -> throw(throwme); init({continue, Pid}) -> diff --git a/tests/libs/estdlib/test_proc_lib.erl b/tests/libs/estdlib/test_proc_lib.erl index c905c0c7cf..7746b9c0b5 100644 --- a/tests/libs/estdlib/test_proc_lib.erl +++ b/tests/libs/estdlib/test_proc_lib.erl @@ -59,6 +59,15 @@ test_start_monitor_badarg() -> error:badarg -> ok end, + %% OTP rejects the tuple form too + ok = + try + proc_lib:start(?MODULE, init_ok, [Parent], infinity, [{monitor, []}]), + unexpected + catch + error:badarg -> + ok + end, ok. test_start_link_sync() ->