Skip to content

Commit c8c738c

Browse files
committed
Isolate warning counts per calling process
This is done by implementing ExDoc.WarningCounter using a GenServer and :counters
1 parent bb43d07 commit c8c738c

File tree

5 files changed

+236
-36
lines changed

5 files changed

+236
-36
lines changed

lib/ex_doc/warning_counter.ex

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,119 @@
11
defmodule ExDoc.WarningCounter do
22
@moduledoc false
33

4-
use Agent
4+
use GenServer
55

6-
def start_link(_opts) do
7-
Agent.start_link(fn -> 0 end, name: __MODULE__)
6+
@type count :: non_neg_integer()
7+
@type counters_ref :: :counters.counters_ref()
8+
@typep caller :: pid() | :default
9+
@typep state :: %{:default => counters_ref(), caller() => counters_ref()}
10+
11+
defguardp is_caller(term) when is_pid(term) or term == :default
12+
13+
###########################
14+
# Callback implementations
15+
16+
@spec start_link(any()) :: GenServer.on_start()
17+
def start_link(arg) do
18+
GenServer.start_link(__MODULE__, arg, name: __MODULE__)
19+
end
20+
21+
@impl GenServer
22+
@spec init(any()) :: {:ok, state}
23+
def init(_args) do
24+
counter_ref = new_counter()
25+
26+
state = %{
27+
self() => counter_ref,
28+
default: counter_ref
29+
}
30+
31+
{:ok, state}
32+
end
33+
34+
@impl GenServer
35+
def handle_call({:count, caller}, _from, state) when is_caller(caller) do
36+
counter_ref = get_counter_ref(state, caller)
37+
count = :counters.get(counter_ref, 1)
38+
39+
{:reply, count, state}
40+
end
41+
42+
@impl GenServer
43+
def handle_info({:increment, caller}, state) when is_caller(caller) do
44+
counter_ref = get_counter_ref(state, caller)
45+
:counters.add(counter_ref, 1, 1)
46+
47+
{:noreply, state}
48+
end
49+
50+
def handle_info({:register_caller, caller}, state) when is_caller(caller) do
51+
counter_ref = new_counter()
52+
state = Map.put(state, caller, counter_ref)
53+
54+
{:noreply, state}
55+
end
56+
57+
def handle_info({:unregister_caller, caller}, state) when is_caller(caller) do
58+
state = Map.delete(state, caller)
59+
60+
{:noreply, state}
861
end
962

10-
def count do
11-
Agent.get(__MODULE__, & &1)
63+
#############
64+
# Public API
65+
66+
@spec count(caller()) :: count()
67+
def count(caller \\ self()) do
68+
GenServer.call(__MODULE__, {:count, caller})
1269
end
1370

14-
def increment do
15-
Agent.update(__MODULE__, &(&1 + 1))
71+
@spec increment() :: :ok
72+
def increment() do
73+
caller = self()
74+
send(__MODULE__, {:increment, caller})
75+
76+
:ok
1677
end
1778

18-
def reset do
19-
Agent.update(__MODULE__, fn _ -> 0 end)
79+
@spec register_caller(caller()) :: :ok
80+
def register_caller(caller) when is_caller(caller) do
81+
send(__MODULE__, {:register_caller, caller})
82+
end
83+
84+
@spec unregister_caller(caller()) :: :ok
85+
def unregister_caller(caller) when is_caller(caller) do
86+
send(__MODULE__, {:unregister_caller, caller})
87+
end
88+
89+
##################
90+
# Private helpers
91+
92+
defp new_counter() do
93+
:counters.new(1, [:atomics])
94+
end
95+
96+
defp get_counter_ref(state, caller)
97+
when is_map(state) and is_caller(caller) and is_map_key(state, caller) do
98+
Map.fetch!(state, caller)
99+
end
100+
101+
defp get_counter_ref(%{default: default_counter_ref} = state, caller)
102+
when is_map(state) and is_caller(caller) do
103+
callers = callers(caller)
104+
105+
Enum.find_value(callers, default_counter_ref, fn the_caller ->
106+
Map.get(state, the_caller)
107+
end)
108+
end
109+
110+
defp callers(pid) when is_pid(pid) do
111+
case Process.info(pid, :dictionary) do
112+
{:dictionary, dictionary} ->
113+
Keyword.get(dictionary, :"$callers", [])
114+
115+
nil ->
116+
[]
117+
end
20118
end
21119
end

lib/mix/tasks/docs.ex

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,13 +388,15 @@ defmodule Mix.Tasks.Docs do
388388
browser_open(index)
389389
end
390390

391-
if options[:warnings_as_errors] == true and ExDoc.WarningCounter.count() > 0 do
391+
warning_counter_count = ExDoc.WarningCounter.count()
392+
393+
if options[:warnings_as_errors] == true and warning_counter_count > 0 do
392394
Mix.shell().info([
393395
:red,
394396
"Doc generation failed due to warnings while using the --warnings-as-errors option"
395397
])
396398

397-
exit({:shutdown, ExDoc.WarningCounter.count()})
399+
exit({:shutdown, warning_counter_count})
398400
else
399401
index
400402
end

test/ex_doc/cli_test.exs

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
defmodule ExDoc.CLITest do
22
use ExUnit.Case, async: true
3+
34
import ExUnit.CaptureIO
5+
import TestHelper, only: [isolated_warning_counter: 1]
46

57
@ebin "_build/test/lib/ex_doc/ebin"
68

@@ -68,34 +70,72 @@ defmodule ExDoc.CLITest do
6870
end
6971

7072
describe "--warnings-as-errors" do
71-
test "exits with code 0 when no warnings" do
72-
{[html, epub], _io} = run(["ExDoc", "1.2.3", @ebin, "--warnings-as-errors"])
73-
74-
assert html ==
75-
{"ExDoc", "1.2.3",
76-
[formatter: "html", apps: [:ex_doc], source_beam: @ebin, warnings_as_errors: true]}
73+
@describetag :warning_counter
7774

78-
assert epub ==
79-
{"ExDoc", "1.2.3",
80-
[formatter: "epub", apps: [:ex_doc], source_beam: @ebin, warnings_as_errors: true]}
75+
test "exits with code 0 when no warnings" do
76+
isolated_warning_counter do
77+
{[html, epub], _io} = run(["ExDoc", "1.2.3", @ebin, "--warnings-as-errors"])
78+
79+
assert html ==
80+
{"ExDoc", "1.2.3",
81+
[
82+
formatter: "html",
83+
formatters: ["html", "epub"],
84+
apps: [:ex_doc],
85+
source_beam: @ebin,
86+
warnings_as_errors: true
87+
]}
88+
89+
assert epub ==
90+
{"ExDoc", "1.2.3",
91+
[
92+
formatter: "epub",
93+
formatters: ["html", "epub"],
94+
apps: [:ex_doc],
95+
source_beam: @ebin,
96+
warnings_as_errors: true
97+
]}
98+
end
8199
end
82100

83101
test "exits with 1 with warnings" do
84-
ExDoc.WarningCounter.increment()
102+
isolated_warning_counter do
103+
ExDoc.WarningCounter.increment()
85104

86-
fun = fn ->
87-
run(["ExDoc", "1.2.3", @ebin, "--warnings-as-errors"])
88-
end
105+
fun = fn ->
106+
run(["ExDoc", "1.2.3", @ebin, "--warnings-as-errors"])
107+
end
89108

90-
io =
91-
capture_io(:stderr, fn ->
92-
assert catch_exit(fun.()) == {:shutdown, 1}
93-
end)
109+
io =
110+
capture_io(:stderr, fn ->
111+
assert catch_exit(fun.()) == {:shutdown, 1}
112+
end)
94113

95-
assert io =~
96-
"Doc generation failed due to warnings while using the --warnings-as-errors option\n"
97-
after
98-
ExDoc.WarningCounter.reset()
114+
assert io =~
115+
"Doc generation failed due to warnings while using the --warnings-as-errors option\n"
116+
end
117+
end
118+
119+
test "exits with 3 with warnings" do
120+
isolated_warning_counter do
121+
ExDoc.WarningCounter.increment()
122+
ExDoc.WarningCounter.increment()
123+
ExDoc.WarningCounter.increment()
124+
ExDoc.WarningCounter.increment()
125+
ExDoc.WarningCounter.increment()
126+
127+
fun = fn ->
128+
run(["ExDoc", "1.2.3", @ebin, "--warnings-as-errors"])
129+
end
130+
131+
io =
132+
capture_io(:stderr, fn ->
133+
assert catch_exit(fun.()) == {:shutdown, 5}
134+
end)
135+
136+
assert io =~
137+
"Doc generation failed due to warnings while using the --warnings-as-errors option\n"
138+
end
99139
end
100140
end
101141

test/mix/tasks/docs_test.exs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ defmodule Mix.Tasks.DocsTest do
55
# Cannot run concurrently due to Mix compile/deps calls
66
use ExUnit.Case, async: false
77

8+
import TestHelper, only: [isolated_warning_counter: 1]
9+
810
@moduletag :tmp_dir
911

1012
def run(context, args, opts) do
@@ -391,9 +393,50 @@ defmodule Mix.Tasks.DocsTest do
391393
end)
392394
end
393395

394-
test "accepts warnings_as_errors in :warnings_as_errors" do
395-
assert catch_exit(run([], app: :ex_doc, docs: [warnings_as_errors: true])) == {:shutdown, 1}
396-
after
397-
ExDoc.WarningCounter.reset()
396+
test "accepts warnings_as_errors in :warnings_as_errors", context do
397+
isolated_warning_counter do
398+
assert [
399+
{"ex_doc", "dev",
400+
[
401+
formatter: "html",
402+
formatters: ["html", "epub"],
403+
deps: _,
404+
apps: [:ex_doc],
405+
source_beam: _,
406+
warnings_as_errors: true,
407+
proglang: :elixir
408+
]},
409+
{"ex_doc", "dev",
410+
[
411+
formatter: "epub",
412+
formatters: ["html", "epub"],
413+
deps: _,
414+
apps: [:ex_doc],
415+
source_beam: _,
416+
warnings_as_errors: true,
417+
proglang: :elixir
418+
]}
419+
] = run(context, [], app: :ex_doc, docs: [warnings_as_errors: true])
420+
end
421+
end
422+
423+
test "exits with 1 due to warning with --warnings_as_errors", context do
424+
isolated_warning_counter do
425+
ExDoc.WarningCounter.increment()
426+
427+
assert catch_exit(run(context, [], app: :ex_doc, docs: [warnings_as_errors: true])) ==
428+
{:shutdown, 1}
429+
end
430+
end
431+
432+
test "exits with 3 due to warning with --warnings_as_errors", context do
433+
isolated_warning_counter do
434+
ExDoc.WarningCounter.increment()
435+
ExDoc.WarningCounter.increment()
436+
ExDoc.WarningCounter.increment()
437+
438+
assert catch_exit(run(context, [], app: :ex_doc, docs: [warnings_as_errors: true])) ==
439+
{:shutdown, 3}
440+
end
398441
end
399442
end

test/test_helper.exs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,21 @@ defmodule TestHelper do
100100
raise "not supported"
101101
end
102102
end
103+
104+
defmacro isolated_warning_counter(do: code) do
105+
quote location: :keep do
106+
task =
107+
Task.async(fn ->
108+
ExDoc.WarningCounter.register_caller(self())
109+
110+
try do
111+
unquote(code)
112+
after
113+
ExDoc.WarningCounter.unregister_caller(self())
114+
end
115+
end)
116+
117+
Task.await(task)
118+
end
119+
end
103120
end

0 commit comments

Comments
 (0)