From f2ea6538dafb8e33043fda3f691c955640995ea9 Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Sun, 8 Nov 2026 00:17:52 -0700 Subject: [PATCH 1/2] Keep the exit status when the CLI cannot print an error to stderr When a script raises and stderr cannot be written to (a closed descriptor, or a pipe whose reader has exited), Kernel.CLI.print_error/3 raised from inside the catch in exec_fun/2, the :DOWN branch tried to print again, and the exception escaped Kernel.CLI.run/1 into init:start_it: the VM reported "Runtime terminating during boot" and wrote an erl_crash.dump into the cwd instead of exiting with status 1. print_error/3 now treats the stderr write as best-effort. Closes #16079 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018wugBUzZrvZNchWxmRqJgH --- lib/elixir/lib/kernel/cli.ex | 5 +++++ lib/elixir/test/elixir/kernel/cli_test.exs | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/elixir/lib/kernel/cli.ex b/lib/elixir/lib/kernel/cli.ex index 08e18c462eb..7aab860325e 100644 --- a/lib/elixir/lib/kernel/cli.ex +++ b/lib/elixir/lib/kernel/cli.ex @@ -186,6 +186,11 @@ defmodule Kernel.CLI do defp print_error(kind, reason, stacktrace) do {banner, rest} = format_error_parts(kind, reason, stacktrace) IO.write(:stderr, [IO.ANSI.format([:red, banner]), rest]) + rescue + # stderr cannot be written to (a closed descriptor, or a pipe whose reader + # has exited): the error cannot be shown, but the exit status still applies + ArgumentError -> :ok + ErlangError -> :ok end defp blame_match(%{match?: true, node: node}), do: blame_ansi(:normal, "+", node) diff --git a/lib/elixir/test/elixir/kernel/cli_test.exs b/lib/elixir/test/elixir/kernel/cli_test.exs index 0407f351c2e..f11fd17fb5a 100644 --- a/lib/elixir/test/elixir/kernel/cli_test.exs +++ b/lib/elixir/test/elixir/kernel/cli_test.exs @@ -184,6 +184,21 @@ defmodule Kernel.CLI.ExecutableTest do "false\n" end + @tag :unix + @tag :tmp_dir + test "exits with status 1 when the error cannot be written to stderr", + %{tmp_dir: tmp_dir, cli_extension: cli_extension} do + # stderr is a pipe whose reader has exited, so printing the error fails; + # the status must still be the error's, and no crash dump written + cmd = + ~c"cd #{tmp_dir} && (#{elixir_executable(cli_extension)} -e \"raise ArgumentError\" " ++ + ~c"2>&1 >/dev/null; echo $? > status) | true" + + :os.cmd(cmd) + assert File.read!(Path.join(tmp_dir, "status")) == "1\n" + refute File.exists?(Path.join(tmp_dir, "erl_crash.dump")) + end + stderr_test "blames exceptions", %{cli_extension: cli_extension} do error = elixir(~c"-e \"Access.fetch :foo, :bar\"", cli_extension) assert error =~ "** (FunctionClauseError) no function clause matching in Access.fetch/2" From 874ef67a6b568be6786562c7c38a04e430007bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sun, 8 Nov 2026 16:16:47 +0100 Subject: [PATCH 2/2] Update lib/elixir/lib/kernel/cli.ex --- lib/elixir/lib/kernel/cli.ex | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/elixir/lib/kernel/cli.ex b/lib/elixir/lib/kernel/cli.ex index 7aab860325e..58056f2a812 100644 --- a/lib/elixir/lib/kernel/cli.ex +++ b/lib/elixir/lib/kernel/cli.ex @@ -185,12 +185,15 @@ defmodule Kernel.CLI do defp print_error(kind, reason, stacktrace) do {banner, rest} = format_error_parts(kind, reason, stacktrace) - IO.write(:stderr, [IO.ANSI.format([:red, banner]), rest]) - rescue - # stderr cannot be written to (a closed descriptor, or a pipe whose reader + error = [IO.ANSI.format([:red, banner]), rest] + + # stderr may not be writable (a closed descriptor, or a pipe whose reader # has exited): the error cannot be shown, but the exit status still applies - ArgumentError -> :ok - ErlangError -> :ok + try do + IO.write(:stderr, error) + rescue + _ -> :ok + end end defp blame_match(%{match?: true, node: node}), do: blame_ansi(:normal, "+", node)