From a1aeb802f251f46f931434d62d7859b294bc5f47 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Thu, 1 Oct 2026 15:58:58 +0300 Subject: [PATCH 1/3] Capture writes to standard_error from evaluated code Erlang has a single standard_error device per node, so output that evaluated code wrote there ended up on the server's console instead of the client. dialtone_stderr takes over the device's name and forwards writes from eval workers (found through their group leader) to their session's IO server, which sends them to the client as err. Everything else goes to the original device, and so do writes from session processes while no request is running. A forwarded write whose IO server dies gets an error instead of waiting forever. Taking over a node-wide device is too much for an embedded server to do on its own, so it's opt-in through dialtone:capture_stderr/0 or the capture_stderr app env. The standalone server turns it on. --- dialtone/README.md | 12 +- dialtone/src/dialtone.erl | 29 ++++- dialtone/src/dialtone_cli.erl | 3 + dialtone/src/dialtone_io_server.erl | 85 ++++++++++---- dialtone/src/dialtone_stderr.erl | 171 ++++++++++++++++++++++++++++ dialtone/src/dialtone_sup.erl | 4 +- dialtone/test/dialtone_SUITE.erl | 125 ++++++++++++++++++++ 7 files changed, 405 insertions(+), 24 deletions(-) create mode 100644 dialtone/src/dialtone_stderr.erl diff --git a/dialtone/README.md b/dialtone/README.md index 8ba2b26..2addf0f 100644 --- a/dialtone/README.md +++ b/dialtone/README.md @@ -51,6 +51,13 @@ The `dialtone` application starts no listener on its own, so it's safe to include in a release. To start servers from configuration, set the `listen` app env to a list of option maps. +`bin/dialtone` sends what evaluated code writes to `standard_error` to +the client, as `err`. An embedded server doesn't do that by default, +because `standard_error` is a single device for the whole node. Call +`dialtone:capture_stderr()` (or set the `capture_stderr` app env to +`true`) if you want it anyway. Writes from the rest of your application +still go to the console. + ## Supported operations `describe`, `clone`, `close`, `ls-sessions`, `eval`, `load-file`, @@ -86,9 +93,8 @@ construction. See `dialtone_backend_erlang` and repartee's ## Known limitations -- Writes to the global `standard_error` device and `logger` output go - to the server's console, not the client. Eval errors themselves are - reported to the client with full stack traces. +- `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. ## Development diff --git a/dialtone/src/dialtone.erl b/dialtone/src/dialtone.erl index d19d58e..636009d 100644 --- a/dialtone/src/dialtone.erl +++ b/dialtone/src/dialtone.erl @@ -1,7 +1,7 @@ %% @doc Public API for the dialtone nREPL server. -module(dialtone). --export([start_server/1, stop_server/1, port/1, version/0]). +-export([start_server/1, stop_server/1, port/1, version/0, capture_stderr/0]). -type server_opts() :: #{port => inet:port_number(), bind => inet:ip_address(), @@ -27,6 +27,33 @@ stop_server(Pid) -> port(Pid) -> dialtone_listener:port(Pid). +%% @doc Send what evaluated code writes to standard_error to the client, as +%% "err", instead of the node's console. standard_error is a single device +%% for the whole node, so this takes over its registered name (writes from +%% everything else still reach the console). That's why it's off by +%% default; the standalone servers turn it on, and so does setting the +%% `capture_stderr' app env. Calling it again is harmless. +-spec capture_stderr() -> ok | {error, term()}. +capture_stderr() -> + try supervisor:start_child(dialtone_sup, dialtone_stderr:child_spec()) of + Result -> started(Result) + catch + exit:{noproc, _} -> {error, not_started} + end. + +started(Result) -> + case Result of + {ok, _} -> ok; + {error, {already_started, _}} -> ok; + {error, already_present} -> + case supervisor:restart_child(dialtone_sup, dialtone_stderr) of + {ok, _} -> ok; + {error, running} -> ok; + Error -> Error + end; + Error -> Error + end. + -spec version() -> binary(). version() -> {ok, Vsn} = application:get_key(dialtone, vsn), diff --git a/dialtone/src/dialtone_cli.erl b/dialtone/src/dialtone_cli.erl index 0b47ec6..3840a1c 100644 --- a/dialtone/src/dialtone_cli.erl +++ b/dialtone/src/dialtone_cli.erl @@ -8,6 +8,9 @@ main() -> Opts = parse_args(init:get_plain_arguments(), #{port => 0}), {ok, _} = application:ensure_all_started(dialtone), + %% A standalone server owns the node, so it can safely take over + %% standard_error and send evaluated code's writes to the client. + ok = dialtone:capture_stderr(), case dialtone:start_server(Opts) of {ok, _Server} -> timer:sleep(infinity); diff --git a/dialtone/src/dialtone_io_server.erl b/dialtone/src/dialtone_io_server.erl index 21033e3..ce5404c 100644 --- a/dialtone/src/dialtone_io_server.erl +++ b/dialtone/src/dialtone_io_server.erl @@ -8,6 +8,9 @@ %% before the eval's value/done, structurally). Output arriving while no %% request is running (stragglers spawned by past evals) is dropped. %% +%% Writes to standard_error from evaluated code arrive via dialtone_stderr +%% (when stderr capture is on) and go out the same way, as "err". +%% %% Input (get_until/get_line/get_chars) is served from a type-ahead buffer; %% when it runs dry the request is parked, the client is told "need-input", %% and a later stdin op resumes the parked read. An empty stdin payload @@ -16,9 +19,11 @@ -behaviour(gen_server). --export([start_link/0, set_sink/3, reset/1, stdin/2]). +-export([start_link/0, set_sink/3, reset/1, stdin/2, is_io_server/1]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2]). +-define(MARKER, '$dialtone_io_server'). + -record(io, {sink :: undefined | {pid(), map()}, %% reads parked waiting for input: {From, ReplyAs, Spec} pending = queue:new() :: queue:queue(), @@ -40,11 +45,24 @@ set_sink(Io, Conn, Req) -> reset(Io) -> gen_server:call(Io, reset, infinity). +%% @doc Whether Pid is a session IO server on this node. +-spec is_io_server(pid()) -> boolean(). +is_io_server(Pid) when node(Pid) =:= node() -> + case erlang:process_info(Pid, dictionary) of + {dictionary, Dict} -> proplists:get_value(?MARKER, Dict) =:= true; + undefined -> false + end; +is_io_server(_Pid) -> + false. + %% @doc Feed input from a stdin op. An empty payload signals EOF. stdin(Io, Data) -> gen_server:call(Io, {stdin, Data}, infinity). init([]) -> + %% Lets dialtone_stderr tell session IO servers from other group + %% leaders without asking the process anything. + put(?MARKER, true), {ok, #io{}}. handle_call({set_sink, Conn, Req}, _From, State) -> @@ -64,19 +82,29 @@ handle_cast(_Msg, State) -> handle_info({io_request, From, ReplyAs, Request}, State) -> {noreply, io_request(From, ReplyAs, Request, State)}; +%% A write to standard_error relayed by dialtone_stderr. The reply goes back +%% to the relay, which passes it on to the writer. With no request running +%% there's no client to send it to, so it's handed back to go to the +%% console as usual. +handle_info({stderr_request, Relay, Tag, _Request}, #io{sink = undefined} = State) -> + Relay ! {stderr_unclaimed, Tag}, + {noreply, State}; +handle_info({stderr_request, Relay, Tag, Request}, #io{sink = Sink} = State) -> + Relay ! {io_reply, Tag, stderr_write(Request, Sink)}, + {noreply, State}; handle_info(_Info, State) -> {noreply, State}. %%% Requests io_request(From, ReplyAs, {put_chars, Encoding, Chars}, State) -> - io_reply(From, ReplyAs, do_put_chars(Encoding, Chars, State)), + io_reply(From, ReplyAs, do_put_chars(Encoding, Chars, <<"out">>, State#io.sink)), State; io_request(From, ReplyAs, {put_chars, Encoding, M, F, A}, State) -> Chars = try apply(M, F, A) catch _:_ -> {error, format} end, case Chars of {error, _} = Error -> io_reply(From, ReplyAs, Error); - _ -> io_reply(From, ReplyAs, do_put_chars(Encoding, Chars, State)) + _ -> io_reply(From, ReplyAs, do_put_chars(Encoding, Chars, <<"out">>, State#io.sink)) end, State; %% Pre-R15 forms without encoding still occur in the wild. @@ -123,13 +151,7 @@ io_request(From, ReplyAs, {requests, Requests}, State) -> %% The composite's reply is the last request's reply; put_chars replies %% are computed inline, so only output requests are legal here (which is %% how the io module uses it). - Reply = lists:foldl( - fun({put_chars, E, C}, _) -> do_put_chars(E, C, State); - ({put_chars, E, M, F, A}, _) -> - do_put_chars(E, try apply(M, F, A) catch _:_ -> <<>> end, State); - (_, Acc) -> Acc - end, ok, Requests), - io_reply(From, ReplyAs, Reply), + io_reply(From, ReplyAs, put_requests(Requests, <<"out">>, State#io.sink)), State; io_request(From, ReplyAs, _Unknown, State) -> io_reply(From, ReplyAs, {error, request}), @@ -141,23 +163,48 @@ io_reply(From, ReplyAs, Reply) -> %%% Output -do_put_chars(Encoding, Chars, #io{sink = Sink}) -> +put_requests(Requests, Stream, Sink) -> + lists:foldl( + fun({put_chars, E, C}, _) -> do_put_chars(E, C, Stream, Sink); + ({put_chars, E, M, F, A}, _) -> + do_put_chars(E, try apply(M, F, A) catch _:_ -> <<>> end, Stream, Sink); + (_, Acc) -> Acc + end, ok, Requests). + +%% The output requests dialtone_stderr relays; anything else stays with the +%% real device. +stderr_write({put_chars, Encoding, Chars}, Sink) -> + do_put_chars(Encoding, Chars, <<"err">>, Sink); +stderr_write({put_chars, Encoding, M, F, A}, Sink) -> + case try apply(M, F, A) catch _:_ -> {error, format} end of + {error, _} = Error -> Error; + Chars -> do_put_chars(Encoding, Chars, <<"err">>, Sink) + end; +stderr_write({put_chars, Chars}, Sink) -> + stderr_write({put_chars, latin1, Chars}, Sink); +stderr_write({put_chars, M, F, A}, Sink) -> + stderr_write({put_chars, latin1, M, F, A}, Sink); +stderr_write({requests, Requests}, Sink) -> + put_requests(Requests, <<"err">>, Sink). + +do_put_chars(Encoding, Chars, Stream, Sink) -> case unicode:characters_to_binary(Chars, Encoding, utf8) of Bin when is_binary(Bin) -> - emit_out(Bin, Sink); + emit(Stream, Bin, Sink); _ -> {error, {no_translation, Encoding, utf8}} end. -emit_value(_Bin, undefined) -> - ok; -emit_value(Bin, {Conn, Req}) -> - dialtone_msg:reply(Conn, Req, #{<<"value">> => Bin}). +emit_value(Bin, Sink) -> + emit(<<"value">>, Bin, Sink). + +emit_out(Bin, Sink) -> + emit(<<"out">>, Bin, Sink). -emit_out(_Bin, undefined) -> +emit(_Stream, _Bin, undefined) -> ok; -emit_out(Bin, {Conn, Req}) -> - dialtone_msg:reply(Conn, Req, #{<<"out">> => Bin}). +emit(Stream, Bin, {Conn, Req}) -> + dialtone_msg:reply(Conn, Req, #{Stream => Bin}). %%% Input diff --git a/dialtone/src/dialtone_stderr.erl b/dialtone/src/dialtone_stderr.erl new file mode 100644 index 0000000..0f3033a --- /dev/null +++ b/dialtone/src/dialtone_stderr.erl @@ -0,0 +1,171 @@ +%% @private Routes writes to standard_error from evaluated code to the client. +%% +%% Erlang has a single standard_error device per node, so there's no +%% per-process way to capture it. This process takes over the registered +%% name and looks at the group leader of every process that writes to it: +%% eval workers (and anything they spawn) have their session's IO server as +%% group leader, so their output is relayed to that IO server, which sends +%% it to the client as "err". Everything else, including output from +%% session processes while no request is running, goes to the original +%% device untouched. The original gets its name back when this process +%% stops. +%% +%% Relayed writes are answered through this process, which watches the IO +%% server: if it goes away before answering, the writer gets an error +%% instead of waiting forever. +%% +%% Taking over a node-wide device is a big hammer for an embedded server, so +%% it's opt-in: see dialtone:capture_stderr/0 and the capture_stderr app env. +-module(dialtone_stderr). + +-behaviour(gen_server). + +-export([start_link/0, child_spec/0]). +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). + +-record(state, {original :: pid() | undefined, + %% relayed writes waiting for their IO server: + %% Tag => {Writer, ReplyAs, Request message, Monitor} + pending = #{} :: #{reference() => {pid(), term(), tuple(), reference()}}}). + +start_link() -> + gen_server:start_link(?MODULE, [], []). + +child_spec() -> + #{id => ?MODULE, + start => {?MODULE, start_link, []}, + type => worker, + shutdown => 5000}. + +init([]) -> + process_flag(trap_exit, true), + Original = original_device(), + _ = case whereis(standard_error) of + undefined -> ok; + _ -> unregister(standard_error) + end, + true = register(standard_error, self()), + _ = case Original of + undefined -> ok; + _ -> monitor(process, Original) + end, + {ok, #state{original = Original}}. + +%% The device to pass everything else to. A restart finds itself (or a +%% previous instance) under the name, so the first one seen is remembered, +%% as long as it's still alive. +original_device() -> + Key = {?MODULE, original}, + case persistent_term:get(Key, undefined) of + Pid when is_pid(Pid) -> + case is_process_alive(Pid) of + true -> Pid; + false -> remember(Key, whereis(standard_error)) + end; + undefined -> + remember(Key, whereis(standard_error)) + end. + +remember(Key, Pid) -> + persistent_term:put(Key, Pid), + Pid. + +handle_call(_Request, _From, State) -> + {reply, {error, unknown_call}, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info({io_request, From, ReplyAs, Request} = Msg, State) when is_pid(From) -> + case put_chars(Request) andalso session_io(From) of + {ok, Io} -> + Tag = make_ref(), + MRef = monitor(process, Io), + Io ! {stderr_request, self(), Tag, Request}, + Pending = State#state.pending, + {noreply, State#state{pending = Pending#{Tag => {From, ReplyAs, Msg, MRef}}}}; + _ -> + forward(Msg, State), + {noreply, State} + end; +handle_info({io_reply, Tag, Reply}, #state{pending = Pending} = State) -> + case maps:take(Tag, Pending) of + {{From, ReplyAs, _Msg, MRef}, Rest} -> + demonitor(MRef, [flush]), + From ! {io_reply, ReplyAs, Reply}, + {noreply, State#state{pending = Rest}}; + error -> + {noreply, State} + end; +handle_info({stderr_unclaimed, Tag}, #state{pending = Pending} = State) -> + case maps:take(Tag, Pending) of + {{_From, _ReplyAs, Msg, MRef}, Rest} -> + demonitor(MRef, [flush]), + forward(Msg, State), + {noreply, State#state{pending = Rest}}; + error -> + {noreply, State} + end; +handle_info({'DOWN', _MRef, process, Pid, _Reason}, #state{original = Pid} = State) -> + {noreply, State#state{original = undefined}}; +handle_info({'DOWN', MRef, process, _Pid, _Reason}, #state{pending = Pending} = State) -> + Gone = maps:filter(fun(_Tag, {_, _, _, M}) -> M =:= MRef end, Pending), + maps:foreach(fun(_Tag, {From, ReplyAs, _Msg, _}) -> + From ! {io_reply, ReplyAs, {error, terminated}} + end, Gone), + {noreply, State#state{pending = maps:without(maps:keys(Gone), Pending)}}; +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, #state{original = Original, pending = Pending} = State) -> + _ = case whereis(standard_error) of + Self when Self =:= self() -> unregister(standard_error); + _ -> ok + end, + _ = case Original of + undefined -> ok; + _ -> try register(standard_error, Original) catch error:badarg -> ok end + end, + %% Whatever is still waiting goes to the original device, so writers + %% in the rest of the node don't see the device vanish under them. + maps:foreach(fun(_Tag, {_, _, Msg, _}) -> forward(Msg, State) end, Pending), + drain(State), + ok. + +drain(State) -> + receive + {io_request, From, _, _} = Msg when is_pid(From) -> + forward(Msg, State), + drain(State) + after 0 -> + ok + end. + +forward(Msg, #state{original = Original}) when is_pid(Original) -> + Original ! Msg, + ok; +forward({io_request, From, ReplyAs, _}, _State) -> + From ! {io_reply, ReplyAs, {error, terminated}}, + ok. + +%% Only output is relayed; anything else (getopts, geometry, ...) is a +%% question about the real device. +put_chars({put_chars, _, _}) -> true; +put_chars({put_chars, _, _, _, _}) -> true; +put_chars({put_chars, _}) -> true; +put_chars({put_chars, _, _, _}) -> true; +put_chars({requests, Requests}) when is_list(Requests) -> lists:all(fun put_chars/1, Requests); +put_chars(_) -> false. + +session_io(From) when node(From) =/= node() -> + none; +session_io(From) -> + case erlang:process_info(From, group_leader) of + {group_leader, Io} -> + case dialtone_io_server:is_io_server(Io) of + true -> {ok, Io}; + false -> none + end; + undefined -> + none + end. diff --git a/dialtone/src/dialtone_sup.erl b/dialtone/src/dialtone_sup.erl index 0006d23..603015c 100644 --- a/dialtone/src/dialtone_sup.erl +++ b/dialtone/src/dialtone_sup.erl @@ -27,5 +27,7 @@ init([]) -> type => supervisor}, #{id => dialtone_server_sup, start => {dialtone_server_sup, start_link, []}, - type => supervisor}], + type => supervisor}] + ++ [dialtone_stderr:child_spec() + || application:get_env(dialtone, capture_stderr, false) =:= true], {ok, {Flags, Children}}. diff --git a/dialtone/test/dialtone_SUITE.erl b/dialtone/test/dialtone_SUITE.erl index a59c34f..26c4577 100644 --- a/dialtone/test/dialtone_SUITE.erl +++ b/dialtone/test/dialtone_SUITE.erl @@ -23,6 +23,9 @@ all() -> missing_code, stdout_streaming, stdout_before_done, + stderr_capture, + stderr_capture_env, + stderr_dead_io_server, stdin_roundtrip, stdin_eof, ls_sessions_and_close, @@ -86,6 +89,9 @@ statuses_of(Msgs) -> out_of(Msgs) -> iolist_to_binary([O || #{<<"out">> := O} <- Msgs]). +err_of(Msgs) -> + iolist_to_binary([E || #{<<"err">> := E} <- Msgs]). + %%% Cases describe_capabilities(Config) -> @@ -563,3 +569,122 @@ ephemeral_sessions_stop(Config) -> #{<<"op">> => <<"completions">>, <<"prefix">> => <<"li">>}, #{<<"op">> => <<"lookup">>, <<"sym">> => <<"lists">>}]), ok = wait_until(fun() -> Sessions() =< Before end). + +stderr_capture(Config) -> + with_fake_device(fun(Fake) -> stderr_capture(Config, Fake) end). + +stderr_capture(Config, Fake) -> + {Session, Client} = clone(?config(client, Config)), + Write = <<"io:format(standard_error, \"proof\", []), ok.">>, + %% off by default: the write goes to the device + {Before, Client2} = eval(Client, Session, Write), + ?assertEqual(<<>>, err_of(Before)), + ?assertEqual(<<"proof">>, fake_device_output(Fake)), + ok = dialtone:capture_stderr(), + ok = dialtone:capture_stderr(), + {Captured, Client3} = eval(Client2, Session, Write), + ?assertEqual(<<"proof">>, err_of(Captured)), + ?assertEqual(<<"ok">>, value_of(Captured)), + %% processes spawned by the eval inherit its group leader... + {Spawned, Client4} = + eval(Client3, Session, + <<"Self = self(), " + "spawn(fun() -> io:format(standard_error, \"kid\", []), Self ! done end), " + "receive done -> ok end.">>), + ?assertEqual(<<"kid">>, err_of(Spawned)), + %% ...but what they write after the eval is done has no client to + %% go to, so it goes to the device + {_, Client5} = + eval(Client4, Session, + <<"spawn(fun() -> timer:sleep(100), " + "io:format(standard_error, \"late\", []) end), ok.">>), + ok = wait_until(fun() -> fake_device_output(Fake) =:= <<"late">> end), + %% writes from anything else go to the device too + ok = io:format(standard_error, "test", []), + ?assertEqual(<<"test">>, fake_device_output(Fake)), + ok = supervisor:terminate_child(dialtone_sup, dialtone_stderr), + ?assertEqual(Fake, whereis(standard_error)), + {After, _} = eval(Client5, Session, Write), + ?assertEqual(<<>>, err_of(After)). + +%% A writer whose IO server goes away before answering gets an error +%% instead of waiting forever. +stderr_dead_io_server(_Config) -> + with_fake_device( + fun(_Fake) -> + ok = dialtone:capture_stderr(), + %% looks like a session IO server, but dies on the first write + Io = spawn(fun() -> + put('$dialtone_io_server', true), + receive {stderr_request, _, _, _} -> exit(gone) end + end), + Self = self(), + _ = spawn(fun() -> + group_leader(Io, self()), + Result = try io:format(standard_error, "x", []) + catch error:Reason -> {error, Reason} + end, + Self ! {written, Result} + end), + receive + {written, Result} -> ?assertMatch({error, _}, Result) + after 2000 -> + ct:fail(writer_hung) + end + end). + +%% Stand in for the node's real standard_error while Fun runs, so tests can +%% see what reaches it, and put everything back afterwards. +with_fake_device(Fun) -> + Real = whereis(standard_error), + Fake = spawn_link(fun fake_device/0), + true = unregister(standard_error), + true = register(standard_error, Fake), + _ = persistent_term:erase({dialtone_stderr, original}), + try + Fun(Fake) + after + _ = supervisor:terminate_child(dialtone_sup, dialtone_stderr), + _ = supervisor:delete_child(dialtone_sup, dialtone_stderr), + _ = persistent_term:erase({dialtone_stderr, original}), + try unregister(standard_error) catch error:badarg -> ok end, + true = register(standard_error, Real), + unlink(Fake), + exit(Fake, kill) + end. + +%% A minimal IO device that remembers what's written to it. +fake_device() -> + fake_device(<<>>). + +fake_device(Acc) -> + receive + {io_request, From, ReplyAs, {put_chars, Enc, M, F, A}} -> + From ! {io_reply, ReplyAs, ok}, + fake_device(<>); + {io_request, From, ReplyAs, {put_chars, Enc, Chars}} -> + From ! {io_reply, ReplyAs, ok}, + fake_device(<>); + {io_request, From, ReplyAs, _} -> + From ! {io_reply, ReplyAs, {error, request}}, + fake_device(Acc); + {take, From} -> + From ! {output, Acc}, + fake_device(<<>>) + end. + +%% What the device got since the last call. +fake_device_output(Fake) -> + Fake ! {take, self()}, + receive {output, Out} -> Out after 1000 -> timeout end. + +%% A capture_stderr app env that isn't true mustn't stop dialtone from +%% starting. +stderr_capture_env(_Config) -> + ok = application:set_env(dialtone, capture_stderr, "true"), + try + {ok, {_, Children}} = dialtone_sup:init([]), + ?assertNot(lists:keymember(dialtone_stderr, 2, [{x, maps:get(id, C)} || C <- Children])) + after + application:unset_env(dialtone, capture_stderr) + end. From 1e705a94e15d889bb4905d604fd0be1d41c7e1a8 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Thu, 1 Oct 2026 15:58:58 +0300 Subject: [PATCH 2/3] Capture standard_error in the standalone repartee server Like bin/dialtone, bin/repartee and mix repartee.server own their node, so they can send what evaluated code writes to :standard_error to the client. That includes Elixir's parse warnings. --- repartee/README.md | 8 +++++--- repartee/lib/repartee/backend.ex | 5 +++-- repartee/lib/repartee/cli.ex | 5 +++++ repartee/test/repartee_integration_test.exs | 9 +++++++++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/repartee/README.md b/repartee/README.md index 16ac67b..291698d 100644 --- a/repartee/README.md +++ b/repartee/README.md @@ -65,9 +65,11 @@ Repartee.port(server) ## Known limitations -- `Logger` output and writes to `:standard_error` go to the server's - console, not the client (eval errors are reported to the client in - full). +- `Logger` output goes to the server's console, not the client (eval + errors are reported to the client in full). Writes to + `:standard_error` reach the client with `mix repartee.server` and + `bin/repartee`. When you embed repartee, call + `:dialtone.capture_stderr()` if you want that too. - Values are rendered with `inspect/2` (IEx-style options, customizable via `:inspect_opts`). diff --git a/repartee/lib/repartee/backend.ex b/repartee/lib/repartee/backend.ex index 7187bf9..0213e76 100644 --- a/repartee/lib/repartee/backend.ex +++ b/repartee/lib/repartee/backend.ex @@ -32,8 +32,9 @@ defmodule Repartee.Backend do line = Map.get(meta, :line, 1) column = Map.get(meta, :column, 1) - # No :emit_warnings here - parse warnings go through the worker's group - # leader, i.e. stream to the client as output, which is where they belong. + # No :emit_warnings here - parse warnings are written to :standard_error, + # which reaches the client as err when stderr capture is on (see + # :dialtone.capture_stderr/0) and the console otherwise. quoted_opts = [ file: to_string(file), line: line, diff --git a/repartee/lib/repartee/cli.ex b/repartee/lib/repartee/cli.ex index 914a745..a3add32 100644 --- a/repartee/lib/repartee/cli.ex +++ b/repartee/lib/repartee/cli.ex @@ -27,6 +27,11 @@ defmodule Repartee.CLI do port_file: port_file ] + # A standalone server owns the node, so it can take over standard_error + # and send what evaluated code writes there to the client. + {:ok, _} = Application.ensure_all_started(:dialtone) + :ok = :dialtone.capture_stderr() + case Repartee.start(start_opts) do {:ok, _server} -> Process.sleep(:infinity) diff --git a/repartee/test/repartee_integration_test.exs b/repartee/test/repartee_integration_test.exs index 6489166..c10c590 100644 --- a/repartee/test/repartee_integration_test.exs +++ b/repartee/test/repartee_integration_test.exs @@ -44,6 +44,15 @@ defmodule Repartee.IntegrationTest do assert value_of(msgs) == "43" end + test "captures stderr when asked to", %{client: client} do + :ok = :dialtone.capture_stderr() + on_exit(fn -> Supervisor.terminate_child(:dialtone_sup, :dialtone_stderr) end) + {session, client} = clone(client) + {msgs, _} = eval(client, session, ~s|IO.write(:stderr, "oops")\n:ok|) + assert Enum.map_join(msgs, &Map.get(&1, "err", "")) == "oops" + assert value_of(msgs) == ":ok" + end + test "streams stdout", %{client: client} do {session, client} = clone(client) {msgs, _} = eval(client, session, ~s|IO.puts("здравей")\n:ok|) From 48573256f60be82fcb3f29752d4cd951c9435387 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Thu, 1 Oct 2026 15:58:58 +0300 Subject: [PATCH 3/3] Show stderr output in chaser as output, not as errors chaser treated every err in a response as a failed eval and printed each chunk as its own error block. Now that dialtone streams stderr as err, plain err chunks are printed as they arrive (in red), and the error block is kept for responses with an ex. --- chaser/src/chaser_render.erl | 7 ++++++- chaser/src/chaser_shell.erl | 7 +++++-- chaser/test/chaser_render_tests.erl | 7 +++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/chaser/src/chaser_render.erl b/chaser/src/chaser_render.erl index 9edf1db..1bbe7c0 100644 --- a/chaser/src/chaser_render.erl +++ b/chaser/src/chaser_render.erl @@ -7,7 +7,7 @@ %% green "=> " prefix; errors a red "!! " summary with the trace indented. -module(chaser_render). --export([new/1, out/2, value/2, err/3, note/2, banner/2, doc/2, prompt/2]). +-export([new/1, out/2, stderr/2, value/2, err/3, note/2, banner/2, doc/2, prompt/2]). -record(r, {color :: boolean(), bol = true :: boolean()}). %% at beginning of line? @@ -26,6 +26,11 @@ new(#{color := Color}) -> out(Chunk, R) -> {Chunk, track(Chunk, R)}. +%% @doc Streamed error output (stderr), printed as the program wrote it, +%% in red when colors are on. +stderr(Chunk, R) -> + {style(?RED, Chunk, R), track(Chunk, R)}. + %% @doc An eval result: `=> Value', with continuation lines indented to %% align under the value. value(Value, R) -> diff --git a/chaser/src/chaser_shell.erl b/chaser/src/chaser_shell.erl index a2e855a..2ca9b8d 100644 --- a/chaser/src/chaser_shell.erl +++ b/chaser/src/chaser_shell.erl @@ -188,10 +188,13 @@ render_msg(Msg, #sh{render = R0} = State) -> #{<<"value">> := Value} -> print(chaser_render:value(Value, R1)); _ -> R1 end, + %% err on its own is the code's stderr, streamed like out; with an ex + %% it's the account of a failed eval. R3 = case Msg of + #{<<"ex">> := Ex} -> + print(chaser_render:err(Ex, maps:get(<<"err">>, Msg, <<>>), R2)); #{<<"err">> := Err} -> - Summary = maps:get(<<"ex">>, Msg, <<"error">>), - print(chaser_render:err(Summary, Err, R2)); + print(chaser_render:stderr(Err, R2)); _ -> R2 end, diff --git a/chaser/test/chaser_render_tests.erl b/chaser/test/chaser_render_tests.erl index 8d86567..3c1a047 100644 --- a/chaser/test/chaser_render_tests.erl +++ b/chaser/test/chaser_render_tests.erl @@ -37,6 +37,13 @@ error_summary_and_indented_detail_test() -> " exception error: bad\n" " in div/2\n">>, Rendered). +stderr_streams_as_written_test() -> + R0 = plain(), + {A, R1} = chaser_render:stderr(<<"a">>, R0), + {B, R2} = chaser_render:stderr(<<"b">>, R1), + {Val, _} = chaser_render:value(<<"ok">>, R2), + ?assertEqual(<<"ab\n=> ok\n">>, unicode:characters_to_binary([A, B, Val])). + error_without_detail_test() -> ?assertEqual(<<"!! interrupted\n">>, render(fun(R) -> chaser_render:err(<<"interrupted">>, <<>>, R) end)).