Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Guessed from commit history
* @Financial-Times/membership
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,43 @@

Code reloader Plug extracted from [Phoenix](https://github.com/phoenixframework/phoenix/) and adapted to be a generic Plug.

So far it's just a proof of concept to understand if having a generic code reload Plug makes sense or not.

## Why

If you have an Elixir web app using only Plug without Phoenix, you need to restart it everytime you update the code.

This module uses `Mix` to compile any files changed on disk every time an HTTP request is received, allowing your server to keep running while you make changes.

If code changes outside of the VM e.g. you call `mix compile` in another shell, or your editor recompiles files automatically, any modules that changed on disk will be re-loaded (the Pheonix version doesn't currently handle this use case).

## Usage

The the example app at [https://github.com/pilu/code-reload-example](https://github.com/pilu/code-reload-example)
Add the `CodeReloader.Server` to your supervision tree, e.g. in your `Application.start/2`:

```
children = [
..., {CodeReloader.Server, [:elixir]}
]
Supervisor.start_link(children, strategy: :one_for_one)
```

The list argument configures the compilers we run when when `CodeReloader.Server.reload!/1` is called: each is called (effectively) as `mix compile.<name>`.


For re-loading code on every HTTP request, add the plug to your `Plug.Router`:

```
plug CodeReloader.Plug, endpoint: __MODULE__
```

The endpoint is used to serialize requests and prevent a compile failure in the router from stopping subsequent re-loading.

If your code has compile errors, an error page will be rendered to the browser; errors and other compile messages also appear on the standard output.


### Using outside of Plug

You can also call `CodeReloader.Server.reload!/1` directly, e.g. for reloading on another event.

```
CodeReloader.Server.reload!(__MODULE__)
```
52 changes: 27 additions & 25 deletions lib/code_reloader/plug.ex

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions lib/code_reloader/proxy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ defmodule CodeReloader.Proxy do

## Callbacks

def init(args) do
{:ok, args}
end

def handle_call(:stop, _from, output) do
{:stop, :normal, output, output}
end
Expand All @@ -33,7 +37,7 @@ defmodule CodeReloader.Proxy do
put_chars(from, reply, apply(m, f, as), output)

{:io_request, _from, _reply, _request} = msg ->
send(Process.group_leader, msg)
send(Process.group_leader(), msg)
{:noreply, output}

_ ->
Expand All @@ -42,7 +46,7 @@ defmodule CodeReloader.Proxy do
end

defp put_chars(from, reply, chars, output) do
send(Process.group_leader, {:io_request, from, reply, {:put_chars, chars}})
send(Process.group_leader(), {:io_request, from, reply, {:put_chars, chars}})
{:noreply, output <> IO.chardata_to_string(chars)}
end
end
156 changes: 114 additions & 42 deletions lib/code_reloader/server.ex
Original file line number Diff line number Diff line change
@@ -1,12 +1,49 @@
defmodule CodeReloader.Server do
@moduledoc false
@moduledoc """
Recompiles modified files in the current mix project by invoking configured reloadable compilers.

Specify the compilers that should be run for reloading when starting the server, e.g.:

```
children = [{CodeReloader.Server, [:elixir, :erlang]}]
Supervisor.start_link(children, [strategy: :one_for_one])
```

Code can then be reloaded by calling:

```
CodeReloader.Server.reload!(mod)
```

where `mod` will normally be a `Plug.Router` module containing the `CodeReloader.Plug`
used to instigate a code reload on every web-server call (it could potentially
be any another module being used to kick-off the reload).

The `mod` argument is used for two purposes:

* To avoid race conditions from multiple calls: all code reloads from the same
module are funneled through a sequential call operation.
* To back-up the module's `.beam` file so if compilation of the module itself fails,
it can be restored to working order, otherwise code reload through that
module would no-longer be available, which would kill an endpoint.

We also keep track of the last time that we compiled the code, so that if the code changes
outside of the VM, e.g. an external tool recompiles the code, we notice that the manifest
is newer than when we compiled, and explicitly reload all modified modules (see `:code.modified_modules/0`)
since compiling will potentially be a no-op.

This code is based on that in the [Pheonix Project](https://github.com/phoenixframework/phoenix),
without the Phoenix dependencies, and modified to deal with the edge-case of projects recompiled
outside of the `CodeReloader.Server` (the original only copes with modified source code).

"""
use GenServer

require Logger
alias CodeReloader.Proxy

def start_link() do
GenServer.start_link(__MODULE__, false, name: __MODULE__)
def start_link(reloadable_compilers) do
GenServer.start_link(__MODULE__, reloadable_compilers, name: __MODULE__)
end

def check_symlinks do
Expand All @@ -19,58 +56,63 @@ defmodule CodeReloader.Server do

## Callbacks

def init(false) do
{:ok, false}
def init(reloadable_compilers) do
{:ok, {false, reloadable_compilers, System.os_time(:seconds)}}
end

def handle_call(:check_symlinks, _from, checked?) do
def handle_call(:check_symlinks, _from, {checked?, reloadable_compilers, last_compile_time}) do
if not checked? and Code.ensure_loaded?(Mix.Project) do
build_path = Mix.Project.build_path()
symlink = Path.join(Path.dirname(build_path), "__phoenix__")
symlink = Path.join(Path.dirname(build_path), "#{__MODULE__}")

case File.ln_s(build_path, symlink) do
:ok ->
File.rm(symlink)

{:error, :eexist} ->
File.rm(symlink)

{:error, _} ->
Logger.warn "Phoenix is unable to create symlinks. Phoenix' code reloader will run " <>
"considerably faster if symlinks are allowed." <> os_symlink(:os.type)
Logger.warn(
"App is unable to create symlinks. CodeReloader will run " <>
"considerably faster if symlinks are allowed." <> os_symlink(:os.type())
)
end
end

{:reply, :ok, true}
{:reply, :ok, {true, reloadable_compilers, last_compile_time}}
end

def handle_call({:reload!, endpoint}, from, state) do
compilers = endpoint.config(:reloadable_compilers)
def handle_call({:reload!, endpoint}, from, {checked?, compilers, last_compile_time}) do
backup = load_backup(endpoint)
froms = all_waiting([from], endpoint)
froms = all_waiting([from], endpoint)

{res, out} =
proxy_io(fn ->
try do
mix_compile(Code.ensure_loaded(Mix.Task), compilers)
mix_compile(Code.ensure_loaded(Mix.Task), compilers, last_compile_time)
catch
:exit, {:shutdown, 1} ->
:error

kind, reason ->
IO.puts Exception.format(kind, reason, System.stacktrace)
IO.puts(Exception.format(kind, reason, System.stacktrace()))
:error
end
end)

reply =
case res do
:ok ->
:ok
{:ok, out}

:error ->
write_backup(backup)
{:error, out}
end

Enum.each(froms, &GenServer.reply(&1, reply))
{:noreply, state}
{:noreply, {checked?, compilers, System.os_time(:seconds)}}
end

def handle_info(_, state) do
Expand All @@ -79,20 +121,22 @@ defmodule CodeReloader.Server do

defp os_symlink({:win32, _}),
do: " On Windows, such can be done by starting the shell with \"Run as Administrator\"."
defp os_symlink(_),
do: ""

defp os_symlink(_), do: ""

defp load_backup(mod) do
mod
|> :code.which()
|> read_backup()
end

defp read_backup(path) when is_list(path) do
case File.read(path) do
{:ok, binary} -> {:ok, path, binary}
_ -> :error
end
end

defp read_backup(_path), do: :error

defp write_backup({:ok, path, file}), do: File.write!(path, file)
Expand All @@ -109,51 +153,60 @@ defmodule CodeReloader.Server do
# TODO: Remove the function_exported call after 1.3 support is removed
# and just use loaded. apply/3 is used to prevent a compilation
# warning.
defp mix_compile({:module, Mix.Task}, compilers) do
if Mix.Project.umbrella? do
defp mix_compile({:module, Mix.Task}, compilers, last_compile_time) do
if Mix.Project.umbrella?() do
deps =
if function_exported?(Mix.Dep.Umbrella, :cached, 0) do
apply(Mix.Dep.Umbrella, :cached, [])
else
Mix.Dep.Umbrella.loaded
Mix.Dep.Umbrella.loaded()
end
Enum.each deps, fn dep ->

Enum.each(deps, fn dep ->
Mix.Dep.in_dependency(dep, fn _ ->
mix_compile_unless_stale_config(compilers)
mix_compile_unless_stale_config(compilers, last_compile_time)
end)
end
end)
else
mix_compile_unless_stale_config(compilers)
mix_compile_unless_stale_config(compilers, last_compile_time)
:ok
end
end
defp mix_compile({:error, _reason}, _) do

defp mix_compile({:error, _reason}, _, _) do
raise "the Code Reloader is enabled but Mix is not available. If you want to " <>
"use the Code Reloader in production or inside an escript, you must add " <>
":mix to your applications list. Otherwise, you must disable code reloading " <>
"in such environments"
"use the Code Reloader in production or inside an escript, you must add " <>
":mix to your applications list. Otherwise, you must disable code reloading " <>
"in such environments"
end

defp mix_compile_unless_stale_config(compilers) do
manifests = Mix.Tasks.Compile.Elixir.manifests
configs = Mix.Project.config_files
defp mix_compile_unless_stale_config(compilers, last_compile_time) do
manifests = Mix.Tasks.Compile.Elixir.manifests()
configs = Mix.Project.config_files()

# did the manifest change outside of us compiling the project?
manifests_last_updated =
Enum.map(manifests, &File.stat!(&1, time: :posix).mtime) |> Enum.max()

out_of_date? = manifests_last_updated > last_compile_time

case Mix.Utils.extract_stale(configs, manifests) do
[] ->
mix_compile(compilers)
do_mix_compile(compilers, out_of_date?)

files ->
raise """
could not compile application: #{Mix.Project.config[:app]}.
could not compile application: #{Mix.Project.config()[:app]}.

You must restart your server after changing the following config or lib files:

* #{Enum.map_join(files, "\n * ", &Path.relative_to_cwd/1)}
"""
end
end
end
end

defp mix_compile(compilers) do
all = Mix.Project.config[:compilers] || Mix.compilers
defp do_mix_compile(compilers, out_of_date?) do
all = Mix.Project.config()[:compilers] || Mix.compilers()

compilers =
for compiler <- compilers, compiler in all do
Expand All @@ -163,23 +216,42 @@ defmodule CodeReloader.Server do

# We call build_structure mostly for Windows so new
# assets in priv are copied to the build directory.
Mix.Project.build_structure
Mix.Project.build_structure()
res = Enum.map(compilers, &Mix.Task.run("compile.#{&1}", []))

if :ok in res && consolidate_protocols?() do
Mix.Task.reenable("compile.protocols")
Mix.Task.run("compile.protocols", [])
end

if(out_of_date?, do: reload_modules())

res
end

defp consolidate_protocols? do
Mix.Project.config[:consolidate_protocols]
Mix.Project.config()[:consolidate_protocols]
end

defp reload_modules() do
:code.modified_modules()
|> Enum.each(fn mod ->
IO.puts("Reloading #{inspect(mod)}\n")

case :code.soft_purge(mod) do
true ->
:code.load_file(mod)

false ->
Process.sleep(500)
:code.purge(mod)
:code.load_file(mod)
end
end)
end

defp proxy_io(fun) do
original_gl = Process.group_leader
original_gl = Process.group_leader()
{:ok, proxy_gl} = Proxy.start()
Process.group_leader(self(), proxy_gl)

Expand Down
14 changes: 8 additions & 6 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ defmodule CodeReloader.Mixfile do
use Mix.Project

def project do
[app: :code_reloader,
version: "0.1.0",
elixir: "~> 1.4",
build_embedded: Mix.env == :prod,
start_permanent: Mix.env == :prod,
deps: deps()]
[
app: :code_reloader,
version: "0.1.0",
elixir: "~> 1.4",
build_embedded: Mix.env() == :prod,
start_permanent: Mix.env() == :prod,
deps: deps()
]
end

# Configuration for the OTP application
Expand Down