From 3f25a7d58627a9d8fed1c489c991da87428513ce Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 18 Oct 2026 23:24:06 +0200 Subject: [PATCH] Return errors instead of raising on corrupt beam in docs and ExCk reads Code.fetch_docs/1 delegates to :beam_lib, which only catches throws. A malformed or truncated .beam makes :beam_lib.read_info/1 raise (e.g. MatchError from a badarg in binary_to_atom), which escaped completion and hover via Normalized.Code.get_docs/2 and callback_documentation/1. - Normalized.Code.fetch_docs/1 now catches any raise/exit/throw and returns {:error, :chunk_not_found}; callback_documentation/1 goes through it. - ExCkReader's on-disk :beam_lib.chunks/2 path catches the same and falls through to the existing binary fallback. Tests mirror the typespec corrupt-beam test from #346. --- lib/elixir_sense/core/exck_reader.ex | 4 +++ lib/elixir_sense/core/normalized/code.ex | 12 ++++++--- test/elixir_sense/core/exck_reader_test.exs | 25 +++++++++++++++++++ .../core/normalized/code_test.exs | 25 +++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/lib/elixir_sense/core/exck_reader.ex b/lib/elixir_sense/core/exck_reader.ex index b2acd1fb..2541ed36 100644 --- a/lib/elixir_sense/core/exck_reader.ex +++ b/lib/elixir_sense/core/exck_reader.ex @@ -137,6 +137,10 @@ defmodule ElixirSense.Core.ExCkReader do _ -> {:error, :not_found} end + catch + # `:beam_lib.chunks/2` raises on malformed beams instead of returning an + # error tuple; fall back to the binary path (which also handles that case). + _, _ -> {:error, :not_found} end defp fetch_from_binary(module) do diff --git a/lib/elixir_sense/core/normalized/code.ex b/lib/elixir_sense/core/normalized/code.ex index c7c7859d..b5b1d3d4 100644 --- a/lib/elixir_sense/core/normalized/code.ex +++ b/lib/elixir_sense/core/normalized/code.ex @@ -192,7 +192,7 @@ defmodule ElixirSense.Core.Normalized.Code do def callback_documentation(module) do app = ElixirSense.Core.Applications.get_application(module) - case Code.fetch_docs(module) do + case fetch_docs(module) do {:docs_v1, _moduledoc_anno, _language, mime_type, _moduledoc, _metadata, docs} when mime_type in @supported_mime_types -> docs @@ -247,6 +247,12 @@ defmodule ElixirSense.Core.Normalized.Code do metadata: map def fetch_docs(module_or_path) - def fetch_docs(module) when is_atom(module), do: Code.fetch_docs(module) - def fetch_docs(path) when is_binary(path), do: Code.fetch_docs(path) + def fetch_docs(module_or_path) when is_atom(module_or_path) or is_binary(module_or_path) do + Code.fetch_docs(module_or_path) + catch + # `Code.fetch_docs/1` delegates to `:beam_lib`, which only catches throws. + # A malformed or truncated .beam raises (e.g. MatchError) instead of + # returning an error tuple, so treat any failure as missing docs. + _, _ -> {:error, :chunk_not_found} + end end diff --git a/test/elixir_sense/core/exck_reader_test.exs b/test/elixir_sense/core/exck_reader_test.exs index d61f1223..d66fd047 100644 --- a/test/elixir_sense/core/exck_reader_test.exs +++ b/test/elixir_sense/core/exck_reader_test.exs @@ -40,6 +40,31 @@ defmodule ElixirSense.Core.ExCkReaderTest do test "handles invalid chunk gracefully" do assert {:error, :invalid_chunk} = ExCkReader.read_chunk(:fake, chunk: <<1, 2, 3>>) end + + @tag :tmp_dir + test "returns error instead of raising when the beam file is corrupt", %{tmp_dir: tmp_dir} do + module = :ElixirSenseCorruptBeamExCkProbe + {_, beam, _} = :code.get_object_code(ElixirSenseExample.ModuleWithTypes) + + # Flip a byte inside the atom table chunk so `:beam_lib.chunks/2` raises + # on the on-disk path instead of returning an error tuple. + <> = beam + corrupt = <> + + File.write!(Path.join(tmp_dir, "#{module}.beam"), corrupt) + tmp_dir_charlist = String.to_charlist(tmp_dir) + :code.add_patha(tmp_dir_charlist) + on_exit(fn -> :code.del_path(tmp_dir_charlist) end) + + # The ExCk chunk itself is intact, so the raw-binary fallback may still + # decode it; the contract under test is only that nothing raises. + assert match?( + {tag, _} when tag in [:ok, :error], + ExCkReader.read_chunk(module, cache: false) + ) + + assert :error == ExCkReader.lookup_signature(module, :foo, 0) + end end test "lookup_signature returns cached entries" do diff --git a/test/elixir_sense/core/normalized/code_test.exs b/test/elixir_sense/core/normalized/code_test.exs index 05d0c2c4..a62434a1 100644 --- a/test/elixir_sense/core/normalized/code_test.exs +++ b/test/elixir_sense/core/normalized/code_test.exs @@ -113,4 +113,29 @@ defmodule ElixirSense.Core.Normalized.CodeTest do assert is_list(Code.get_docs(:gen_server, :callback_docs)) assert is_tuple(Code.get_docs(:lists, :moduledoc)) end + + describe "malformed beam files" do + @tag :tmp_dir + test "return nil instead of raising when the beam is corrupt", %{tmp_dir: tmp_dir} do + module = :ElixirSenseCorruptBeamDocsProbe + {_, beam, _} = :code.get_object_code(ElixirSenseExample.ModuleWithTypes) + + # Flip a byte inside the atom table chunk: `:beam_lib.read_info/1` raises a + # MatchError here instead of returning an error tuple, which used to escape + # `Code.fetch_docs/1`. + <> = beam + corrupt = <> + + File.write!(Path.join(tmp_dir, "#{module}.beam"), corrupt) + tmp_dir_charlist = String.to_charlist(tmp_dir) + :code.add_patha(tmp_dir_charlist) + on_exit(fn -> :code.del_path(tmp_dir_charlist) end) + + assert {:error, _} = Code.fetch_docs(module) + assert nil == Code.get_docs(module, :docs) + assert nil == Code.get_docs(module, :type_docs) + assert nil == Code.get_docs(module, :callback_docs) + assert nil == Code.get_docs(module, :moduledoc) + end + end end