Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added version compatibility check #332

Merged
merged 4 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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