Skip to content

Commit 17359c4

Browse files
authored
Merge pull request #15 from esl/typed-server
Sketch the TypedServer module and import the example
2 parents b2a8c6d + 82389de commit 17359c4

File tree

17 files changed

+605
-0
lines changed

17 files changed

+605
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Used by "mix format"
2+
[
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4+
]
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# The directory Mix will write compiled artifacts to.
2+
/_build/
3+
4+
# If you run "mix test --cover", coverage assets end up here.
5+
/cover/
6+
7+
# The directory Mix downloads your dependencies sources to.
8+
/deps/
9+
10+
# Where third-party dependencies like ExDoc output generated docs.
11+
/doc/
12+
13+
# Ignore .fetch files in case you like to edit your project deps locally.
14+
/.fetch
15+
16+
# If the VM crashes, it generates a dump, let's ignore it too.
17+
erl_crash.dump
18+
19+
# Also ignore archive artifacts (built via "mix archive.build").
20+
*.ez
21+
22+
# Ignore package tarball (built via "mix hex.build").
23+
typed_gen_server-*.tar
24+
25+
26+
# Temporary files for e.g. tests
27+
/tmp
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# TypedGenServer
2+
3+
This is an experiment which abuses Gradualizer's exhaustiveness checking
4+
to type message passing contracts.
5+
6+
This does not automatically determine the types of messages passed around - it's
7+
the programmer who's responsbile for providing this information in form of a type
8+
defining the message.
9+
10+
However, given that type in place, the techniques used here make it easier
11+
to catch bugs if:
12+
13+
- some messages are not handled (completely forgotten or added to the
14+
contract, but not to the implementation)
15+
- some messages get malformed (think typos, pattern match mistakes, etc)
16+
- some responses are not handled (i.e. response handlers are incomplete,
17+
for example if new response types were introduced after the handler was
18+
in place)
19+
20+
I hope you're interested ;)
21+
If so, please check out [`lib/typed_gen_server/multi_server.ex`](/lib/typed_gen_server/multi_server.ex).
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
defmodule Contract.Echo do
2+
@type req :: {:echo_req, String.t()}
3+
@type res :: {:echo_res, String.t()}
4+
end
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
defmodule Contract.Hello do
2+
@type req :: {:hello, String.t()}
3+
@type res :: :ok
4+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
defmodule TypedGenServer do
2+
@moduledoc """
3+
Documentation for `TypedGenServer`.
4+
"""
5+
6+
@doc """
7+
Hello world.
8+
9+
## Examples
10+
11+
iex> TypedGenServer.hello()
12+
:world
13+
14+
"""
15+
def hello do
16+
:world
17+
end
18+
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
defmodule TypedGenServer.Application do
2+
# See https://hexdocs.pm/elixir/Application.html
3+
# for more information on OTP Applications
4+
@moduledoc false
5+
6+
use Application
7+
8+
@impl true
9+
def start(_type, _args) do
10+
children = [
11+
# Starts a worker by calling: TypedGenServer.Worker.start_link(arg)
12+
# {TypedGenServer.Worker, arg}
13+
]
14+
15+
# See https://hexdocs.pm/elixir/Supervisor.html
16+
# for other strategies and supported options
17+
opts = [strategy: :one_for_one, name: TypedGenServer.Supervisor]
18+
Supervisor.start_link(children, opts)
19+
end
20+
end
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
defmodule TypedGenServer.Stage1.Server do
2+
use GenServer
3+
use GradualizerEx.TypeAnnotation
4+
5+
## Start IEx with:
6+
## iex -S mix run --no-start
7+
##
8+
## Then use the following to recheck the file on any change:
9+
## recompile(); GradualizerEx.type_check_file(:code.which( TypedGenServer.Stage1.Server ), [:infer])
10+
11+
## Try switching between the definitions and see what happens
12+
@type message :: Contract.Echo.req() | Contract.Hello.req()
13+
#@type message :: Contract.Echo.req()
14+
#@type message :: {:echo_req, String.t()} | {:hello, String.t()}
15+
16+
@type state :: map()
17+
18+
def start_link() do
19+
GenServer.start_link(__MODULE__, %{})
20+
end
21+
22+
@spec echo(pid(), String.t()) :: String.t()
23+
# @spec echo(pid(), String.t()) :: {:echo_req, String.t()}
24+
def echo(pid, message) do
25+
case annotate_type( GenServer.call(pid, {:echo_req, message}), Contract.Echo.res() ) do
26+
#case call_echo(pid, message) do
27+
## Try changing the pattern or the returned response
28+
{:echo_res, response} -> response
29+
end
30+
end
31+
32+
#@spec call_echo(pid(), String.t()) :: Contract.Echo.res()
33+
#defp call_echo(pid, message) do
34+
# GenServer.call(pid, {:echo_req, message})
35+
#end
36+
37+
@spec hello(pid(), String.t()) :: :ok
38+
def hello(pid, name) do
39+
case GenServer.call(pid, {:hello, name}) |> annotate_type(Contract.Hello.res()) do
40+
:ok -> :ok
41+
end
42+
end
43+
44+
@impl true
45+
def init(state) do
46+
{:ok, state}
47+
end
48+
49+
@impl true
50+
def handle_call(m, from, state) do
51+
{:noreply, handle(m, from, state)}
52+
end
53+
54+
@spec handle(message(), any, any) :: state()
55+
## Try breaking the pattern match, e.g. by changing 'echo_req'
56+
def handle({:echo_req, payload}, from, state) do
57+
GenServer.reply(from, {:echo_res, payload})
58+
state
59+
end
60+
61+
## Try commenting out the following clause
62+
def handle({:hello, name}, from, state) do
63+
IO.puts("Hello, #{name}!")
64+
GenServer.reply(from, :ok)
65+
state
66+
end
67+
end
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
defmodule TypedGenServer.Stage2.Server do
2+
use GenServer
3+
use GradualizerEx.TypeAnnotation
4+
alias Stage2.TypedServer
5+
6+
## Start IEx with:
7+
## iex -S mix run --no-start
8+
##
9+
## Then use the following to recheck the file on any change:
10+
## recompile(); GradualizerEx.type_check_file(:code.which( TypedGenServer.Stage2.Server ), [:infer])
11+
12+
@opaque t :: pid()
13+
14+
## Try switching between the definitions and see what happens
15+
@type message :: Contract.Echo.req() | Contract.Hello.req()
16+
#@type message :: Contract.Echo.req()
17+
#@type message :: {:echo_req, String.t()} | {:hello, String.t()}
18+
19+
@type state :: map()
20+
21+
@spec start_link() :: {:ok, t()} | :ignore | {:error, {:already_started, t()} | any()}
22+
def start_link() do
23+
GenServer.start_link(__MODULE__, %{})
24+
end
25+
26+
@spec echo(t(), String.t()) :: String.t()
27+
# @spec echo(t(), String.t()) :: {:echo_req, String.t()}
28+
def echo(pid, message) do
29+
case annotate_type( GenServer.call(pid, {:echo_req, message}), Contract.Echo.res() ) do
30+
#case call_echo(pid, message) do
31+
## Try changing the pattern or the returned response
32+
{:echo_res, response} -> response
33+
end
34+
end
35+
36+
## This could be generated based on present handle clauses - thanks, Robert!
37+
@spec call_echo(t(), String.t()) :: Contract.Echo.res()
38+
defp call_echo(pid, message) do
39+
GenServer.call(pid, {:echo_req, message})
40+
end
41+
42+
@spec hello(t(), String.t()) :: :ok
43+
def hello(pid, name) do
44+
case GenServer.call(pid, {:hello, name}) |> annotate_type(Contract.Hello.res()) do
45+
:ok -> :ok
46+
end
47+
end
48+
49+
@impl true
50+
def init(state) do
51+
{:ok, state}
52+
end
53+
54+
@impl true
55+
def handle_call(m, from, state) do
56+
{:noreply, handle(m, from, state)}
57+
end
58+
59+
@spec handle(message(), any, any) :: state()
60+
## Try breaking the pattern match, e.g. by changing 'echo_req'
61+
def handle({:echo_req, payload}, from, state) do
62+
## This could register {:echo_req, payload} <-> {:echo_res, payload} mapping
63+
## and response type at compile time to generate call_echo() automatically.
64+
## Thanks Robert!
65+
#TypedServer.reply( from, {:echo_res, payload}, Contract.Echo.res() )
66+
GenServer.reply( from, {:echo_res, payload} )
67+
state
68+
end
69+
70+
## Try commenting out the following clause
71+
def handle({:hello, name}, from, state) do
72+
IO.puts("Hello, #{name}!")
73+
GenServer.reply(from, :ok)
74+
state
75+
end
76+
end
77+
78+
defmodule Test.TypedGenServer.Stage2.Server do
79+
alias TypedGenServer.Stage2.Server
80+
81+
## Typecheck with:
82+
## recompile(); GradualizerEx.type_check_file(:code.which( Test.TypedGenServer.Stage2.Server ), [:infer])
83+
84+
@spec test :: any()
85+
def test do
86+
{:ok, srv} = Server.start_link()
87+
pid = self()
88+
"payload" = Server.echo(srv, "payload")
89+
## This won't typecheck, since Server.echo only accepts Server.t(), that is our Server pids
90+
#"payload" = Server.echo(pid, "payload")
91+
end
92+
end
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
defmodule Stage3.TypedServer do
2+
3+
## This doesn't play well with:
4+
## {:ok, srv} = MultiServer.start_link()
5+
## Due to:
6+
## The pattern {ok, _srv@1} on line 90 doesn't have the type 'Elixir.TypedServer':on_start(t())
7+
## A bug/limitation in Gradualizer pattern match typing?
8+
@type on_start(t) :: {:ok, t} | :ignore | {:error, {:already_started, t} | any()}
9+
10+
def wrap(on_start, module) do
11+
case on_start do
12+
{:ok, pid} -> {:ok, {module, pid}}
13+
{:error, {:already_started, pid}} -> {:error, {:already_started, {module, pid}}}
14+
other -> other
15+
end
16+
end
17+
end
18+
19+
defmodule TypedGenServer.Stage3.Server do
20+
use GenServer
21+
use GradualizerEx.TypeAnnotation
22+
alias Stage3.TypedServer
23+
24+
## Start IEx with:
25+
## iex -S mix run --no-start
26+
##
27+
## Then use the following to recheck the file on any change:
28+
## recompile(); GradualizerEx.type_check_file(:code.which( TypedGenServer.Stage3.Server ), [:infer])
29+
30+
@opaque t :: {__MODULE__, pid()}
31+
32+
## Try switching between the definitions and see what happens
33+
@type message :: Contract.Echo.req() | Contract.Hello.req()
34+
#@type message :: Contract.Echo.req()
35+
#@type message :: {:echo_req, String.t()} | {:hello, String.t()}
36+
37+
@type state :: map()
38+
39+
@spec start_link() :: TypedServer.on_start(t())
40+
def start_link() do
41+
GenServer.start_link(__MODULE__, %{}) |> TypedServer.wrap(__MODULE__)
42+
end
43+
44+
@spec echo(t(), String.t()) :: String.t()
45+
# @spec echo(t(), String.t()) :: {:echo_req, String.t()}
46+
def echo(_server = {__MODULE__, _pid}, message) do
47+
case annotate_type( GenServer.call(_pid, {:echo_req, message}), Contract.Echo.res() ) do
48+
#case call_echo(_server, message) do
49+
## Try changing the pattern or the returned response
50+
{:echo_res, response} -> response
51+
end
52+
end
53+
54+
#@spec call_echo(t(), String.t()) :: Contract.Echo.res()
55+
#defp call_echo({__MODULE__, pid}, message) do
56+
# GenServer.call(pid, {:echo_req, message})
57+
#end
58+
59+
@spec hello(t(), String.t()) :: :ok
60+
def hello({__MODULE__, pid}, name) do
61+
case GenServer.call(pid, {:hello, name}) |> annotate_type(Contract.Hello.res()) do
62+
:ok -> :ok
63+
end
64+
end
65+
66+
@impl true
67+
def init(state) do
68+
{:ok, state}
69+
end
70+
71+
@impl true
72+
def handle_call(m, from, state) do
73+
{:noreply, handle(m, from, state)}
74+
end
75+
76+
@spec handle(message(), any, any) :: state()
77+
## Try breaking the pattern match, e.g. by changing 'echo_req'
78+
def handle({:echo_req, payload}, from, state) do
79+
GenServer.reply(from, {:echo_res, payload})
80+
state
81+
end
82+
83+
## Try commenting out the following clause
84+
def handle({:hello, name}, from, state) do
85+
IO.puts("Hello, #{name}!")
86+
GenServer.reply(from, :ok)
87+
state
88+
end
89+
end
90+
91+
defmodule Test.TypedGenServer.Stage3.Server do
92+
alias TypedGenServer.Stage3.Server
93+
94+
## Typecheck with:
95+
## recompile(); GradualizerEx.type_check_file(:code.which( Test.TypedGenServer.Stage3.Server ), [:infer])
96+
97+
@spec test :: any()
98+
def test do
99+
{:ok, srv} = Server.start_link()
100+
pid = self()
101+
"payload" = Server.echo(srv, "payload")
102+
## This won't typecheck, since Server.echo only accepts Server.t(), that is Server pids
103+
#"payload" = Server.echo(pid, "payload")
104+
end
105+
end

0 commit comments

Comments
 (0)