Skip to content

Commit

Permalink
Added version compatibility check (#332)
Browse files Browse the repository at this point in the history
* Added version compatibility check

Lexical now outputs the erlang and elixir versions of the VM that was
used to create them. It now checks the build and packaging directories
for these versioning files and emits an error message if the vm starts
with beam files that won't work.

* Detected version change on call to package

Another belt to wear with the suspenders. When we build a package that
overwrites another package, if the version has changed, we delete the
old compiled code and the old package and start compilation from
scratch. This definitely prevents old beam files from ending up in the
new package.
  • Loading branch information
scohen committed Aug 23, 2023
1 parent b32c58a commit 7670907
Show file tree
Hide file tree
Showing 6 changed files with 470 additions and 2 deletions.
222 changes: 222 additions & 0 deletions apps/common/lib/lexical/vm/versions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
defmodule Lexical.VM.Versions do
@moduledoc """
Reads and writes version tags for elixir and erlang
When compiling, it is important to node which version of the VM and elixir runtime
were used to build the beam files, as beam files compiled on a newer version of the
VM cannot be used on older versions.
This module allows a directory to be tagged with the versions of elixir and erlang
used as compilation artifacts, and also allows the user to ask if a certain version
is compatible with the currently running VM.
"""

@type version_string :: String.t()

@type t :: %{elixir: version_string(), erlang: version_string()}
@type versioned_t :: %{elixir: Version.t(), erlang: Version.t()}

@doc """
Returns the versions of elixir and erlang in the currently running VM
"""
@spec current() :: t
def current do
%{
elixir: elixir_version(),
erlang: erlang_version()
}
end

@doc """
Returns the compiled-in versions of elixir and erlang.
This function uses the code server to find `.elixir` and `.erlang` files in the code path.
Each of these files represent the version of the runtime the artifact was compiled with.
"""
@spec compiled() :: {:ok, t} | {:error, atom()}
def compiled do
with {:ok, elixir_path} <- code_find_file(version_file(:elixir)),
{:ok, erlang_path} <- code_find_file(version_file(:erlang)),
{:ok, elixir_version} <- read_file(elixir_path),
{:ok, erlang_version} <- read_file(erlang_path) do
{:ok, %{elixir: String.trim(elixir_version), erlang: String.trim(erlang_version)}}
end
end

@doc """
Converts the values of a version map into `Version` structs
"""
@spec to_versions(t) :: versioned_t()
def to_versions(%{elixir: elixir, erlang: erlang}) do
%{elixir: to_version(elixir), erlang: to_version(erlang)}
end

@doc """
Tells whether or not the current version of VM is supported by
Lexical's compiled artifacts.
"""
@spec compatible?() :: boolean
@spec compatible?(Path.t()) :: boolean
def compatible? do
case code_find_file(version_file(:erlang)) do
{:ok, path} ->
path
|> Path.dirname()
|> compatible?()

:error ->
false
end
end

def compatible?(directory) do
system = current()

case read(directory) do
{:ok, tagged} ->
system_erlang = to_version(system.erlang)
tagged_erlang = to_version(tagged.erlang)

tagged_erlang.major <= system_erlang.major

_ ->
false
end
end

@doc """
Returns true if the current directory has version tags for
both elixir and erlang in it.
"""
def tagged?(directory) do
with true <- File.exists?(version_file_path(directory, :elixir)) do
File.exists?(version_file_path(directory, :erlang))
end
end

@doc """
Writes version tags in the given directory, overwriting any that are present
"""
def write(directory) do
write_erlang_version(directory)
write_elixir_version(directory)
end

@doc """
Reads all the version tags in the given directory.
This function will fail if one or both tags is missing
"""
def read(directory) do
with {:ok, elixir} <- read_elixir_version(directory),
{:ok, erlang} <- read_erlang_version(directory) do
{:ok, %{elixir: String.trim(elixir), erlang: String.trim(erlang)}}
end
end

defp write_erlang_version(directory) do
directory
|> version_file_path(:erlang)
|> write_file!(erlang_version())
end

defp write_elixir_version(directory) do
directory
|> version_file_path(:elixir)
|> write_file!(elixir_version())
end

defp read_erlang_version(directory) do
directory
|> version_file_path(:erlang)
|> read_file()
end

defp read_elixir_version(directory) do
directory
|> version_file_path(:elixir)
|> read_file()
end

defp elixir_version do
System.version()
end

defp erlang_version do
major = :otp_release |> :erlang.system_info() |> List.to_string()
version_file = Path.join([:code.root_dir(), "releases", major, "OTP_VERSION"])

try do
{:ok, contents} = read_file(version_file)
String.split(contents, "\n", trim: true)
else
[full] -> full
_ -> major
catch
:error ->
major
end
end

defp version_file_path(directory, language) do
Path.join(directory, version_file(language))
end

defp version_file(language) do
".#{language}"
end

defp normalize(erlang_version) do
# Erlang doesn't use versions compabible with semantic versioning,
# this will make it compatible, as whatever the last number represents
# won't introduce vm-level incompatibilities.

version_components =
erlang_version
|> String.split(".")
|> Enum.take(3)

normalized =
case version_components do
[major] -> [major, "0", "0"]
[major, minor] -> [major, minor, "0"]
[_, _, _] = version -> version
[major, minor, patch | _] -> [major, minor, patch]
end

Enum.join(normalized, ".")
end

require Logger

defp code_find_file(file_name) when is_binary(file_name) do
file_name
|> String.to_charlist()
|> code_find_file()
end

defp code_find_file(file_name) do
Logger.info("file name is #{file_name}")

case :code.where_is_file(file_name) do
:non_existing ->
:error

path ->
{:ok, List.to_string(path)}
end
end

defp to_version(version) when is_binary(version) do
version |> normalize() |> Version.parse!()
end

# these functions exist for testing. I was getting process killed with
# patch if we patch the File module directly
defp write_file!(path, contents) do
File.write!(path, contents)
end

defp read_file(path) do
File.read(path)
end
end
115 changes: 115 additions & 0 deletions apps/common/test/lexical/vm/versions_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
defmodule Lexical.VM.VersionTest do
alias Lexical.VM.Versions
use ExUnit.Case
use Patch
import Versions

test "it gets the current version" do
assert current().elixir == System.version()
end

test "it gets the current erlang version" do
patch(Versions, :erlang_version, fn -> "25.3.2.1" end)
assert current().erlang == "25.3.2.1"
end

test "it reads the versions in a directory" do
patch(Versions, :read_file, fn "/foo/bar/baz/" <> file ->
if String.ends_with?(file, ".erlang") do
{:ok, "25.3.2.2"}
else
{:ok, "14.5.2"}
end
end)

assert {:ok, tags} = read("/foo/bar/baz")

assert tags.elixir == "14.5.2"
assert tags.erlang == "25.3.2.2"
end

test "it writes the versions" do
patch(Versions, :erlang_version, "25.3.2.1")
patch(Versions, :write_file!, :ok)

elixir_version = System.version()

assert write("/foo/bar/baz")
assert_called(Versions.write_file!("/foo/bar/baz/.erlang", "25.3.2.1"))
assert_called(Versions.write_file!("/foo/bar/baz/.elixir", ^elixir_version))
end

def patch_system_versions(elixir, erlang) do
patch(Versions, :elixir_version, elixir)
patch(Versions, :erlang_version, erlang)
end

def patch_tagged_versions(elixir, erlang) do
patch(Versions, :read_file, fn file ->
if String.ends_with?(file, ".elixir") do
{:ok, elixir}
else
{:ok, erlang}
end
end)
end

def with_exposed_normalize(_) do
expose(Versions, normalize: 1)
:ok
end

describe "normalize/1" do
setup [:with_exposed_normalize]

test "fixes a two-element version" do
assert "25.0.0" == private(Versions.normalize("25.0"))
end

test "keeps three-element versions the same" do
assert "25.3.2" == private(Versions.normalize("25.3.2"))
end

test "truncates versions with more than three elements" do
assert "25.3.2" == private(Versions.normalize("25.3.2.2"))

# I can't imagine they'd do this, but, you know, belt and suspenders
assert "25.3.2" == private(Versions.normalize("25.3.2.1.2"))
assert "25.3.2" == private(Versions.normalize("25.3.2.4.2.3"))
end
end

test "an untagged directory is not compatible" do
refute compatible?(System.tmp_dir!())
end

describe "compatible?/1" do
test "lower major versions of erlang are compatible with later major versions" do
patch_system_versions("1.14.5", "26.0")
patch_tagged_versions("1.14.5", "25.0")

assert compatible?("/foo/bar/baz")
end

test "higher major versions are not compatible with lower major versions" do
patch_system_versions("1.14.5", "25.0")
patch_tagged_versions("1.14.5", "26.0")

refute compatible?("/foo/bar/baz")
end

test "the same versions are compatible with each other" do
patch_system_versions("1.14.5", "25.3.3")
patch_tagged_versions("1.14.5", "25.0")

assert compatible?("/foo/bar/baz")
end

test "higher minor versions are compatible" do
patch_system_versions("1.14.5", "25.3.0")
patch_tagged_versions("1.14.5", "25.0")

assert compatible?("/foo/bar/baz")
end
end
end
11 changes: 11 additions & 0 deletions apps/remote_control/lib/lexical/remote_control/build/state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ defmodule Lexical.RemoteControl.Build.State do
alias Lexical.RemoteControl.Build
alias Lexical.RemoteControl.CodeIntelligence
alias Lexical.RemoteControl.Plugin
alias Lexical.VM.Versions

require Logger

import Messages
Expand Down Expand Up @@ -48,8 +50,17 @@ defmodule Lexical.RemoteControl.Build.State do
project = state.project
build_path = Project.build_path(project)

unless Versions.compatible?(build_path) do
Logger.info("Build path #{build_path} was compiled on a previous erlang version. Deleting")

if File.exists?(build_path) do
File.rm_rf(build_path)
end
end

unless File.exists?(build_path) do
File.mkdir_p!(build_path)
Versions.write(build_path)
end
end

Expand Down
1 change: 1 addition & 0 deletions apps/server/lib/lexical/server/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Lexical.Server.Application do

alias Lexical.Server.Provider
alias Lexical.Server.Transport

use Application

@impl true
Expand Down
Loading

0 comments on commit 7670907

Please sign in to comment.