From 59a6df4376a6b4846d754bf7e3bd0852b11aa716 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sat, 7 Nov 2026 09:21:22 +0200 Subject: [PATCH 1/4] Don't report an eval killed by its own code as interrupted A worker that died with reason killed was always answered with interrupted, so an eval that ran exit(self(), kill), or got killed by something else on the node, looked as if the client had interrupted it. The session now remembers that it killed the worker for an interrupt, and any other kill is an eval-error. --- dialtone/src/dialtone_session.erl | 21 +++++++++++---------- dialtone/test/dialtone_SUITE.erl | 12 ++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/dialtone/src/dialtone_session.erl b/dialtone/src/dialtone_session.erl index bd28366..8c47637 100644 --- a/dialtone/src/dialtone_session.erl +++ b/dialtone/src/dialtone_session.erl @@ -25,7 +25,7 @@ running :: undefined | #{req := map(), reply_to := pid(), worker := pid(), mref := reference(), - ref := reference()}, + ref := reference(), interrupted => true}, queue = queue:new() :: queue:queue({map(), pid()})}). start_link(Id, Backend, InitialBState) -> @@ -97,16 +97,17 @@ handle_info({eval_result, Ref, Result}, next(State#session{running = undefined, bstate = NewBState}); handle_info({'DOWN', MRef, process, _Pid, Reason}, #session{running = #{mref := MRef, req := Req, - reply_to := ReplyTo}} = State) -> - %% Worker died without reporting a result: killed (interrupt) or took an - %% exit we didn't catch (e.g. from a linked process). State unchanged. + reply_to := ReplyTo} = Running} = State) -> + %% Worker died without reporting a result: killed by an interrupt, or by + %% an exit we didn't catch (from a linked process, exit(self(), kill), + %% ...). Only the first is interrupted. State unchanged. ok = dialtone_io_server:reset(State#session.io), - case Reason of - killed -> + case Running of + #{interrupted := true} -> dialtone_msg:reply_done(ReplyTo, Req, [interrupted], #{}); - Other -> + _ -> {BMod, _} = State#session.backend, - ErrMap = dialtone_err:render(BMod, exit, Other, [], State#session.bstate), + ErrMap = dialtone_err:render(BMod, exit, Reason, [], State#session.bstate), dialtone_msg:reply_done(ReplyTo, Req, ['eval-error'], ErrMap) end, next(State#session{running = undefined}); @@ -269,14 +270,14 @@ handle_interrupt(Req, ReplyTo, #session{running = undefined, queue = Q} = State) interrupt_queued(Req, ReplyTo, State) end; handle_interrupt(Req, ReplyTo, - #session{running = #{req := RunningReq, worker := Worker}} = State) -> + #session{running = #{req := RunningReq, worker := Worker} = Running} = State) -> RunningId = maps:get(<<"id">>, RunningReq, undefined), case maps:get(<<"interrupt-id">>, Req, RunningId) of RunningId -> exit(Worker, kill), %% The DOWN handler answers the interrupted eval; ack right away. dialtone_msg:reply_done(ReplyTo, Req, #{}), - State; + State#session{running = Running#{interrupted => true}}; _Other -> case interrupt_queued_id(maps:get(<<"interrupt-id">>, Req), State) of {found, State2} -> diff --git a/dialtone/test/dialtone_SUITE.erl b/dialtone/test/dialtone_SUITE.erl index 434c4f3..d3483c2 100644 --- a/dialtone/test/dialtone_SUITE.erl +++ b/dialtone/test/dialtone_SUITE.erl @@ -18,6 +18,7 @@ all() -> multi_form_eval, syntax_error, runtime_error, + eval_killed, unknown_op, unsupported_backend_op, missing_code, @@ -153,6 +154,17 @@ runtime_error(Config) -> {Msgs2, _} = eval(?config(client, Config), Session, <<"ok.">>), ?assertEqual(<<"ok">>, value_of(Msgs2)). +%% Only an interrupt makes an eval interrupted. A worker that gets killed +%% some other way failed. +eval_killed(Config) -> + {Session, Client} = clone(?config(client, Config)), + {Msgs, _} = eval(Client, Session, <<"exit(self(), kill).">>), + Statuses = statuses_of(Msgs), + ?assert(lists:member(<<"eval-error">>, Statuses)), + ?assertNot(lists:member(<<"interrupted">>, Statuses)), + {Msgs2, _} = eval(?config(client, Config), Session, <<"ok.">>), + ?assertEqual(<<"ok">>, value_of(Msgs2)). + unknown_op(Config) -> {[Resp], _} = dialtone_client:request(?config(client, Config), #{<<"op">> => <<"frobnicate">>}), From 44b3366b60d71d8a79134e2dbae6269d0a60313d Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sat, 7 Nov 2026 09:21:55 +0200 Subject: [PATCH 2/4] Show UTF-8 binaries and strings as text in bin/dialtone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Values go through ~tp, which prints <<"здравей"/utf8>> as a list of bytes unless the node runs with +pc unicode. bin/dialtone owns its node, so it turns that on. An embedded server keeps the node's setting. --- dialtone/README.md | 5 ++++- dialtone/bin/dialtone | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/dialtone/README.md b/dialtone/README.md index 2addf0f..e54670e 100644 --- a/dialtone/README.md +++ b/dialtone/README.md @@ -95,7 +95,10 @@ construction. See `dialtone_backend_erlang` and repartee's - `logger` output goes to the server's console, not the client. Eval errors themselves are reported to the client with full stack traces. -- Values are rendered with `~tp` and truncated beyond 8 MB. +- Values are rendered with `~tp` and truncated beyond 8 MB. `~tp` shows + UTF-8 binaries and strings as text only on a node started with + `+pc unicode`, which `bin/dialtone` does. An embedded server prints + them the way your node does. ## Development diff --git a/dialtone/bin/dialtone b/dialtone/bin/dialtone index e2b55ac..809c520 100755 --- a/dialtone/bin/dialtone +++ b/dialtone/bin/dialtone @@ -7,4 +7,4 @@ EBIN="$DIR/_build/default/lib/dialtone/ebin" if [ ! -d "$EBIN" ]; then (cd "$DIR" && rebar3 compile >/dev/null) || exit 1 fi -exec erl -noshell -pa "$EBIN" -eval 'dialtone_cli:main()' -extra "$@" +exec erl +pc unicode -noshell -pa "$EBIN" -eval 'dialtone_cli:main()' -extra "$@" From 936ab8e346fcb0d89c76f3fdd032548e2499ed8d Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sat, 7 Nov 2026 09:48:18 +0200 Subject: [PATCH 3/4] Report interrupted only when the interrupt's kill ended the worker A worker that died of something else just before an interrupt reached it, with its DOWN still on its way, was answered with interrupted, so its error got lost. --- dialtone/src/dialtone_session.erl | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dialtone/src/dialtone_session.erl b/dialtone/src/dialtone_session.erl index 8c47637..204167a 100644 --- a/dialtone/src/dialtone_session.erl +++ b/dialtone/src/dialtone_session.erl @@ -102,8 +102,10 @@ handle_info({'DOWN', MRef, process, _Pid, Reason}, %% an exit we didn't catch (from a linked process, exit(self(), kill), %% ...). Only the first is interrupted. State unchanged. ok = dialtone_io_server:reset(State#session.io), - case Running of - #{interrupted := true} -> + case {Running, Reason} of + %% A worker that died of something else before the interrupt's + %% kill reached it failed. + {#{interrupted := true}, killed} -> dialtone_msg:reply_done(ReplyTo, Req, [interrupted], #{}); _ -> {BMod, _} = State#session.backend, From 9218e0561138aef54b2aadc1705745079f4f4b5e Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sat, 7 Nov 2026 09:47:02 +0200 Subject: [PATCH 4/4] Keep the bindings of the forms before one that fails When a later form in an eval failed, the session went back to its state from before the eval, so `X = 1. error(boom).` left X unbound, although the client had already seen its value. In the shell each form is a line of its own, and X would stay. Now the forms that ran keep their bindings. --- dialtone/src/dialtone_backend_erlang.erl | 10 +++++++++- dialtone/test/dialtone_SUITE.erl | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/dialtone/src/dialtone_backend_erlang.erl b/dialtone/src/dialtone_backend_erlang.erl index e4bf6c7..d55b202 100644 --- a/dialtone/src/dialtone_backend_erlang.erl +++ b/dialtone/src/dialtone_backend_erlang.erl @@ -393,7 +393,15 @@ eval_forms([Exprs | Rest], Bindings0, State, EmitAll) -> true -> dialtone_backend:emit_value(format_value(Value)); false -> ok end, - eval_forms(Rest, Bindings, State, EmitAll). + %% A later form that fails keeps the bindings of this one, as they + %% would be in the shell, where each form is a line of its own. + try + eval_forms(Rest, Bindings, State, EmitAll) + catch + Class:Reason:Stack -> + {error, dialtone_err:render(?MODULE, Class, Reason, Stack, State), + State#{bindings := Bindings}} + end. format_value(Value) -> io_lib:format("~tp", [Value]). diff --git a/dialtone/test/dialtone_SUITE.erl b/dialtone/test/dialtone_SUITE.erl index d3483c2..b005861 100644 --- a/dialtone/test/dialtone_SUITE.erl +++ b/dialtone/test/dialtone_SUITE.erl @@ -16,6 +16,7 @@ all() -> clone_and_eval, bindings_persist, multi_form_eval, + multi_form_error_keeps_bindings, syntax_error, runtime_error, eval_killed, @@ -136,6 +137,16 @@ multi_form_eval(Config) -> ?assertEqual([<<"1">>], [V || #{<<"value">> := V} <- Msgs3]), ?assert(lists:member(<<"eval-error">>, statuses_of(Msgs3))). +%% The forms before the one that fails keep their bindings, which the +%% client has already seen the values of. +multi_form_error_keeps_bindings(Config) -> + {Session, Client} = clone(?config(client, Config)), + {Msgs, _} = eval(Client, Session, <<"Kept = 1. error(proof).">>), + ?assertEqual([<<"1">>], [V || #{<<"value">> := V} <- Msgs]), + ?assert(lists:member(<<"eval-error">>, statuses_of(Msgs))), + {Msgs2, _} = eval(?config(client, Config), Session, <<"Kept.">>), + ?assertEqual(<<"1">>, value_of(Msgs2)). + syntax_error(Config) -> {Session, Client} = clone(?config(client, Config)), {Msgs, _} = eval(Client, Session, <<"1 +.">>),