Skip to content

Fix escript archive layout and support priv #13730

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

Merged
merged 7 commits into from
Jul 23, 2024
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
88 changes: 59 additions & 29 deletions lib/mix/lib/mix/tasks/escript.build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,6 @@ defmodule Mix.Tasks.Escript.Build do
the compiled `.beam` files to reduce the size of the escript.
If this is not desired, check the `:strip_beams` option.

> #### `priv` directory support {: .warning}
>
> escripts do not support projects and dependencies
> that need to store or read artifacts from the priv directory.

## Command line options

Expects the same command line options as `mix compile`.
Expand Down Expand Up @@ -94,6 +89,11 @@ defmodule Mix.Tasks.Escript.Build do
* `:emu_args` - emulator arguments to embed in the escript file.
Defaults to `""`.

* `:include_priv_for` - a list of application names (atoms) specifying
applications which priv directory should be included in the resulting
escript archive. Currently the expected way of accessing priv files
in an escript is via `:escript.extract/2`. Defaults to `[]`.
Comment on lines +94 to +95
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also be more strict and say that they should not rely on the exact archive layout, instead they should do something like this:

priv_path = :code.priv_dir(:app_name)
escript_path = Path.expand(:escript.script_name())
priv_in_archive = Path.relative_to(priv_path, escript_path)

(Ideally they would use :erl_prim_loader, but using it with escript is apparently deprecated, because it will change in the future)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FTR the deprecation note for escript + :erl_prim_loader was added in erlang/otp#8091.


There is one project-level option that affects how the escript is generated:

* `language: :elixir | :erlang` - set it to `:erlang` for Erlang projects
Expand Down Expand Up @@ -185,11 +185,16 @@ defmodule Mix.Tasks.Escript.Build do

escript_mod = String.to_atom(Atom.to_string(app) <> "_escript")

include_priv_for = MapSet.new(escript_opts[:include_priv_for] || [])

beam_paths =
[project_files(), deps_files(), core_files(escript_opts, language)]
[
project_files(project, include_priv_for),
deps_files(include_priv_for),
core_files(escript_opts, language, include_priv_for)
]
|> Stream.concat()
|> prepare_beam_paths()
|> Map.merge(consolidated_paths(project))
|> replace_consolidated_paths(project)

tuples = gen_main(project, escript_mod, main, app, language) ++ read_beams(beam_paths)
tuples = if strip_options, do: strip_beams(tuples, strip_options), else: tuples
Expand All @@ -213,28 +218,40 @@ defmodule Mix.Tasks.Escript.Build do
:ok
end

defp project_files() do
get_files(Mix.Project.app_path())
defp project_files(project, include_priv_for) do
get_files(Mix.Project.app_path(), project[:app] in include_priv_for)
end

defp get_files(app) do
Path.wildcard("#{app}/ebin/*.{app,beam}") ++
(Path.wildcard("#{app}/priv/**/*") |> Enum.filter(&File.regular?/1))
defp get_files(app_path, include_priv?) do
paths = Path.wildcard("#{app_path}/ebin/*.{app,beam}")

paths =
if include_priv? do
paths ++ (Path.wildcard("#{app_path}/priv/**/*") |> Enum.filter(&File.regular?/1))
else
paths
end

apps_dir = Path.dirname(app_path)

for path <- paths do
{Path.relative_to(path, apps_dir), path}
end
end

defp set_perms(filename) do
stat = File.stat!(filename)
:ok = File.chmod(filename, stat.mode ||| 0o111)
end

defp deps_files() do
defp deps_files(include_priv_for) do
deps = Mix.Dep.cached()
Enum.flat_map(deps, fn dep -> get_files(dep.opts[:build]) end)
Enum.flat_map(deps, fn dep -> get_files(dep.opts[:build], dep.app in include_priv_for) end)
end

defp core_files(escript_opts, language) do
defp core_files(escript_opts, language, include_priv_for) do
if Keyword.get(escript_opts, :embed_elixir, language == :elixir) do
Enum.flat_map([:elixir | extra_apps()], &app_files/1)
Enum.flat_map([:elixir | extra_apps()], &app_files(&1, include_priv_for))
else
[]
end
Expand Down Expand Up @@ -269,17 +286,13 @@ defmodule Mix.Tasks.Escript.Build do
end
end

defp app_files(app) do
defp app_files(app, include_priv_for) do
case :code.where_is_file(~c"#{app}.app") do
:non_existing -> Mix.raise("Could not find application #{app}")
file -> get_files(Path.dirname(Path.dirname(file)))
file -> get_files(Path.dirname(Path.dirname(file)), app in include_priv_for)
end
end

defp prepare_beam_paths(paths) do
for path <- paths, into: %{}, do: {Path.basename(path), path}
end

defp read_beams(items) do
Enum.map(items, fn {basename, beam_path} ->
{String.to_charlist(basename), File.read!(beam_path)}
Expand All @@ -305,14 +318,31 @@ defmodule Mix.Tasks.Escript.Build do
end
end

defp consolidated_paths(config) do
defp replace_consolidated_paths(files, config) do
# We could write modules to a consolidated/ directory and prepend
# it to code path using VM args. However, when Erlang Escript
# boots, it prepends all second-level ebin/ directories to the
# path, so the unconsolidated modules would take precedence.
#
# Instead of writing consolidated/ into the archive, we replace
# the protocol modules with their consolidated version in their
# usual location. As a side benefit, this reduces the Escript
# file size, since we do not include the unconsolidated modules.

if config[:consolidate_protocols] do
Mix.Project.consolidation_path(config)
|> Path.join("*")
|> Path.wildcard()
|> prepare_beam_paths()
consolidation_path = Mix.Project.consolidation_path(config)

consolidated =
consolidation_path
|> Path.join("*")
|> Path.wildcard()
|> Map.new(fn path -> {Path.basename(path), path} end)

for {zip_path, path} <- files do
{zip_path, consolidated[Path.basename(path)] || path}
end
else
%{}
[]
end
end

Expand Down
19 changes: 19 additions & 0 deletions lib/mix/test/fixtures/escript_test/lib/escript_test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ defmodule EscriptTest do
IO.inspect(Application.get_env(:foobar, :nesting, "TEST"))
end

def main(["--app-paths"]) do
elixir_path = Application.app_dir(:elixir) |> String.to_charlist()
escript_test_path = Application.app_dir(:escript_test) |> String.to_charlist()

IO.inspect({
elixir_path != escript_test_path,
match?({:ok, _}, :erl_prim_loader.list_dir(elixir_path)),
match?({:ok, _}, :erl_prim_loader.list_dir(escript_test_path))
})
end

def main(["--list-priv", app_name]) do
# Note: :erl_prim_loader usage with Escript is currently deprecated,
# but we use it only in tests for convenience

app = String.to_atom(app_name)
:erl_prim_loader.list_dir(~c"#{:code.lib_dir(app)}/priv") |> IO.inspect()
end

def main(_argv) do
IO.puts(Application.get_env(:foobar, :value, "TEST"))
end
Expand Down
42 changes: 42 additions & 0 deletions lib/mix/test/mix/tasks/escript_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ defmodule Mix.Tasks.EscriptTest do
end
end

defmodule EscriptWithPrivs do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is fine to leave this as is, but I think these tests could be written without defining modules. We have a way of pushing updated configuration when we do Mix.Project.push, which simplify not having to create a bunch of modules.

We should probably refactor this whole test case to use said style, so it could be done later anyway.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean Mix.ProjectStack.post_config/1? I can refactor in a separate PR :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes!!

def project do
[
app: :escript_test_with_priv,
version: "0.0.1",
escript: [main_module: EscriptTest, include_priv_for: [:escript_test_with_priv, :ok]],
deps: [{:ok, path: fixture_path("deps_status/deps/ok")}]
]
end
end

test "generate escript" do
in_fixture("escript_test", fn ->
Mix.Project.push(Escript)
Expand All @@ -104,6 +115,13 @@ defmodule Mix.Tasks.EscriptTest do
assert_received {:mix_shell, :info, ["Generated escript escript_test with MIX_ENV=dev"]}
assert System.cmd("escript", ["escript_test"]) == {"TEST\n", 0}
assert count_abstract_code("escript_test") == 0

# Each app has a distinct, valid path
assert System.cmd("escript", ["escript_test", "--app-paths"]) == {"{true, true, true}\n", 0}

# Does not include priv by default
assert System.cmd("escript", ["escript_test", "--list-priv", "escript_test"]) ==
{":error\n", 0}
end)
end

Expand Down Expand Up @@ -229,6 +247,10 @@ defmodule Mix.Tasks.EscriptTest do
assert_received {:mix_shell, :info, [^message]}

assert System.cmd("escript", ["escript_test_with_deps"]) == {"TEST\n", 0}

# Does not include priv for deps by default
assert System.cmd("escript", ["escript_test_with_deps", "--list-priv", "ok"]) ==
{":error\n", 0}
end)
after
purge([Ok.MixProject])
Expand Down Expand Up @@ -280,6 +302,26 @@ defmodule Mix.Tasks.EscriptTest do
end)
end

test "generate escript with priv" do
in_fixture("escript_test", fn ->
Mix.Project.push(EscriptWithPrivs)

Mix.Tasks.Escript.Build.run([])

message = "Generated escript escript_test_with_priv with MIX_ENV=dev"
assert_received {:mix_shell, :info, [^message]}

assert System.cmd("escript", [
"escript_test_with_priv",
"--list-priv",
"escript_test_with_priv"
]) == {~s/{:ok, [~c"hello"]}\n/, 0}

assert System.cmd("escript", ["escript_test_with_priv", "--list-priv", "ok"]) ==
{~s/{:ok, [~c"sample"]}\n/, 0}
end)
end

test "escript install and uninstall" do
File.rm_rf!(tmp_path(".mix/escripts"))

Expand Down
Loading