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)). 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. 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|)