Skip to content

Commit c3a9766

Browse files
fix: stop idle-reaper restart resurrection leaking dm volumes (#48)
1 parent e5e09ef commit c3a9766

8 files changed

Lines changed: 194 additions & 11 deletions

File tree

lib/hyper/node/img.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ defmodule Hyper.Node.Img do
2020
alias Hyper.Node.Img.ThinPool
2121

2222
@registry Hyper.Node.Img.Registry
23+
@mutable_registry Hyper.Node.Img.MutableRegistry
2324
@server_sup Hyper.Node.Img.Supervisor
2425
@mutable_sup Hyper.Node.Img.MutableSupervisor
2526

@@ -31,6 +32,7 @@ defmodule Hyper.Node.Img do
3132
def init(_opts) do
3233
children = [
3334
{Registry, keys: :unique, name: @registry},
35+
{Registry, keys: :unique, name: @mutable_registry},
3436
ThinPool,
3537
{DynamicSupervisor, strategy: :one_for_one, name: @server_sup},
3638
{DynamicSupervisor, strategy: :one_for_one, name: @mutable_sup}
@@ -42,6 +44,10 @@ defmodule Hyper.Node.Img do
4244
@doc false
4345
def registry, do: @registry
4446

47+
@doc false
48+
@spec mutable_registry() :: atom()
49+
def mutable_registry, do: @mutable_registry
50+
4551
@doc "Activate `img_id` on this node: start (or reuse) its image server."
4652
@spec activate(Hyper.Img.id()) :: {:ok, pid()} | {:error, term()}
4753
def activate(img_id) do
@@ -55,6 +61,9 @@ defmodule Hyper.Node.Img do
5561
@doc "Create a per-VM mutable layer for `vm_id` over `img_id`."
5662
@spec create_mutable(Hyper.Img.id(), Hyper.Vm.Id.t()) :: {:ok, pid()} | {:error, term()}
5763
def create_mutable(img_id, vm_id) do
64+
# Unlike activate/1, we intentionally do NOT map {:already_started, pid} -> {:ok, pid}:
65+
# vm_ids are unique per VM, so a duplicate vm_id is a bug, not a shared-server reuse.
66+
# Surfacing {:error, {:already_started, pid}} enforces the one-mutable-per-vm invariant.
5867
case DynamicSupervisor.start_child(
5968
@mutable_sup,
6069
{Mutable, %Mutable.Opts{img_id: img_id, vm_id: vm_id}}

lib/hyper/node/img/mutable.ex

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ defmodule Hyper.Node.Img.Mutable do
1616
down the RO chain in turn).
1717
"""
1818

19-
use GenServer
19+
# `:temporary` is load-bearing: on idle this server destroys its per-VM thin
20+
# volume in `terminate/2`, so a `:permanent` restart would resurrect the dm
21+
# device it just tore down. See the reconciliation TODO in `Hyper.Node.Reaper`
22+
# for why coupling resource lifetime to process lifetime is a smell.
23+
use GenServer, restart: :temporary
2024

2125
alias Hyper.Node.Img
2226
alias Hyper.Node.Img.{Server, ThinPool}
@@ -47,7 +51,8 @@ defmodule Hyper.Node.Img.Mutable do
4751
end
4852

4953
@spec start_link(Opts.t()) :: GenServer.on_start()
50-
def start_link(%Opts{} = opts), do: GenServer.start_link(__MODULE__, opts)
54+
def start_link(%Opts{vm_id: vm_id} = opts),
55+
do: GenServer.start_link(__MODULE__, opts, name: via(vm_id))
5156

5257
@spec blk_path(GenServer.server()) :: Path.t()
5358
def blk_path(server), do: GenServer.call(server, :blk_path)
@@ -146,6 +151,14 @@ defmodule Hyper.Node.Img.Mutable do
146151
@spec dm_name(Hyper.Vm.Id.t()) :: String.t()
147152
def dm_name(vm_id), do: "hyper-rw-#{sanitize(vm_id)}"
148153

154+
@doc "vm_ids of every live mutable layer on this node."
155+
@spec active_vm_ids() :: [Hyper.Vm.Id.t()]
156+
def active_vm_ids do
157+
Registry.select(Img.mutable_registry(), [{{:"$1", :_, :_}, [], [:"$1"]}])
158+
end
159+
160+
defp via(vm_id), do: {:via, Registry, {Img.mutable_registry(), vm_id}}
161+
149162
defp sanitize(id), do: String.replace(id, ~r/[^A-Za-z0-9._-]/, "_")
150163

151164
@spec drop(State.t(), pid()) :: State.t()

lib/hyper/node/img/server.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ defmodule Hyper.Node.Img.Server do
1212
and releasing its layers (which then unmount once nothing else holds them).
1313
"""
1414

15-
use GenServer
15+
# `:temporary` is load-bearing: on idle this server removes its shared image dm
16+
# chain in `terminate/2`, so a `:permanent` restart would resurrect the chain
17+
# it just tore down. See the reconciliation TODO in `Hyper.Node.Reaper` for why
18+
# coupling resource lifetime to process lifetime is a smell.
19+
use GenServer, restart: :temporary
1620
require Logger
1721

1822
alias Hyper.Img.Db

lib/hyper/node/layer/server.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ defmodule Hyper.Node.Layer.Server do
99
grace period keeps bursty acquire/release cycles from thrashing the mount.
1010
"""
1111

12-
use GenServer
12+
# `:temporary` is load-bearing: on idle this server removes its layer's dm
13+
# mount in `terminate/2`, so a `:permanent` restart would resurrect the device
14+
# it just tore down. See the reconciliation TODO in `Hyper.Node.Reaper` for why
15+
# coupling resource lifetime to process lifetime is a smell.
16+
use GenServer, restart: :temporary
1317
require Logger
1418

1519
alias Hyper.Node.Layer

lib/hyper/node/reaper.ex

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ defmodule Hyper.Node.Reaper do
66
whose vm_id never reboots (so `Hyper.Node.Reclaim`, which runs once at boot, and
77
the relaunch-time cleanup in the FireVMM path, never get a chance to clear it).
88
9-
Liveness is the whole point. The reaper consults two independent sources of
10-
truth for "this vm is alive" (`Plan.orphans/3` removes their union from the
11-
candidate set) and only ever touches `hyper-rw-*` dm names and per-VM cgroup
12-
leaves - never `hyper-thinpool`, `hyper-img-*`, or a live VM's resources. A
9+
Liveness is the whole point. The reaper consults three independent sources of
10+
truth for "this vm is alive" — the local VM supervisor's children, the cluster
11+
routing table, and the node's live mutable layers (`Img.Mutable.active_vm_ids/0`)
12+
— (`Plan.orphans/3` removes their union from the candidate set) and only ever
13+
touches `hyper-rw-*` dm names and per-VM cgroup leaves - never `hyper-thinpool`,
14+
`hyper-img-*`, or a live VM's resources. A
1315
candidate must also survive two consecutive ticks (`Plan.confirm/2`) before it
1416
is reaped, so a VM caught mid-boot (resources present, not yet registered) is
1517
given a grace tick rather than destroyed.
@@ -66,6 +68,16 @@ defmodule Hyper.Node.Reaper do
6668
Process.send_after(self(), :tick, Unit.Time.as_ms(@interval))
6769
end
6870

71+
# TODO(arch): this sweep is a periodic reconciler (desired VMs vs actual
72+
# dm/cgroup state) bolted on as a *backstop* to per-process `terminate/2`
73+
# cleanup. That split is the root fragility behind the `restart: :temporary`
74+
# requirement in `Img.Mutable`/`Img.Server`/`Layer.Server`: resource lifetime is
75+
# coupled to BEAM-process lifetime, so restart strategy is load-bearing and this
76+
# reaper must independently reconstruct liveness (which then has to stay in sync
77+
# with the real owners -- the exact drift that stranded/reaped live volumes).
78+
# A sturdier design promotes this reconciliation to the *primary* cleanup
79+
# mechanism, demoting `terminate/2` to an optimization rather than a correctness
80+
# requirement -- at which point restart strategy stops being load-bearing.
6981
@spec sweep(t()) :: t()
7082
@decorate with_span("Hyper.Node.Reaper.sweep")
7183
defp sweep(%__MODULE__{} = state) do
@@ -81,10 +93,19 @@ defmodule Hyper.Node.Reaper do
8193
end
8294

8395
# Over-counting "live" only defers a reap (safe); under-counting destroys a live
84-
# VM (catastrophic). So union two independent liveness sources: the local VM
85-
# supervisor's children and the cluster routing table's view of this node.
96+
# VM (catastrophic). Union three independent liveness sources: the local VM
97+
# supervisor's children, the cluster routing table's view of this node, and the
98+
# live mutable layers (which own the hyper-rw volumes and exist during the
99+
# mid-boot and idle-grace windows when a vm is in neither of the other two).
86100
@spec gather_live() :: MapSet.t(String.t())
87-
defp gather_live, do: MapSet.union(local_live(), routed_live())
101+
defp gather_live do
102+
local_live()
103+
|> MapSet.union(routed_live())
104+
|> MapSet.union(mutable_live())
105+
end
106+
107+
@spec mutable_live() :: MapSet.t(String.t())
108+
defp mutable_live, do: MapSet.new(Img.Mutable.active_vm_ids())
88109

89110
@spec local_live() :: MapSet.t(String.t())
90111
defp local_live do
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
defmodule Hyper.Node.Img.MutableTest do
2+
use ExUnit.Case, async: false
3+
4+
alias Hyper.Node.Img
5+
alias Hyper.Node.Img.Mutable
6+
7+
setup do
8+
# The full app does not start in the test env, so stand up just the registry
9+
# the mutable layers register into. Reuse the real name so we exercise the
10+
# exact lookup `active_vm_ids/0` performs.
11+
name = Img.mutable_registry()
12+
13+
unless Process.whereis(name) do
14+
start_supervised!({Registry, keys: :unique, name: name})
15+
end
16+
17+
:ok
18+
end
19+
20+
defp register_live(vm_id) do
21+
test = self()
22+
23+
{:ok, pid} =
24+
Task.start_link(fn ->
25+
{:ok, _} = Registry.register(Img.mutable_registry(), vm_id, nil)
26+
send(test, {:registered, vm_id})
27+
Process.sleep(:infinity)
28+
end)
29+
30+
assert_receive {:registered, ^vm_id}
31+
pid
32+
end
33+
34+
test "active_vm_ids lists the vm_ids of every live mutable layer" do
35+
register_live("vm-a")
36+
register_live("vm-b")
37+
38+
assert Enum.sort(Mutable.active_vm_ids()) == ["vm-a", "vm-b"]
39+
end
40+
41+
test "active_vm_ids drops a vm_id once its mutable layer dies" do
42+
pid = register_live("vm-gone")
43+
ref = Process.monitor(pid)
44+
Process.unlink(pid)
45+
Process.exit(pid, :kill)
46+
assert_receive {:DOWN, ^ref, :process, ^pid, _}
47+
48+
# Registry removes the entry on the registered process's :DOWN. Poll briefly
49+
# so the test is robust to that async unregister without a fixed sleep.
50+
assert eventually(fn -> Mutable.active_vm_ids() == [] end)
51+
end
52+
53+
defp eventually(fun, attempts \\ 50) do
54+
cond do
55+
fun.() ->
56+
true
57+
58+
attempts == 0 ->
59+
false
60+
61+
true ->
62+
Process.sleep(2)
63+
eventually(fun, attempts - 1)
64+
end
65+
end
66+
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
defmodule Hyper.Node.Reaper.LivenessTest do
2+
# Guards the Img.Mutable.active_vm_ids/0 + Plan.orphans/3 contract: a live mutable
3+
# layer protects its vm_id from reaping. The gather_live/0 union of this source into
4+
# the full live set is verified by manual live-node testing (Task 4), not CI.
5+
use ExUnit.Case, async: false
6+
7+
alias Hyper.Node.Img
8+
alias Hyper.Node.Img.Mutable
9+
alias Hyper.Node.Reaper.Plan
10+
11+
setup do
12+
name = Img.mutable_registry()
13+
14+
unless Process.whereis(name) do
15+
start_supervised!({Registry, keys: :unique, name: name})
16+
end
17+
18+
:ok
19+
end
20+
21+
defp register_live(vm_id) do
22+
test = self()
23+
24+
{:ok, _pid} =
25+
Task.start_link(fn ->
26+
{:ok, _} = Registry.register(Img.mutable_registry(), vm_id, nil)
27+
send(test, {:registered, vm_id})
28+
Process.sleep(:infinity)
29+
end)
30+
31+
assert_receive {:registered, ^vm_id}
32+
end
33+
34+
test "a vm with a live mutable layer is never an orphan, even with a leftover rw device" do
35+
register_live("vm-live")
36+
37+
live = MapSet.new(Mutable.active_vm_ids())
38+
39+
# The reaper would see hyper-rw-vm-live in `dmsetup ls` (rw candidate) and no
40+
# cgroup leaf, yet the live mutable owner must protect it from reaping.
41+
assert Plan.orphans(live, [], ["vm-live"]) == MapSet.new([])
42+
end
43+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule Hyper.Node.RefcountedRestartTest do
2+
use ExUnit.Case, async: true
3+
4+
# These three servers are monitor-refcounted and idle-reap: when their last
5+
# holder goes away they self-terminate with `{:stop, :normal}`, destroying an
6+
# external device-mapper resource in `terminate/2`. Under a DynamicSupervisor a
7+
# `:permanent` child is RESTARTED on that intentional `:normal` exit, and its
8+
# `init/1` re-creates the resource it just tore down -- an endless resurrection
9+
# loop that leaks dm devices. They MUST be `:temporary` so idle-teardown is
10+
# final. This pins that invariant for every server in the refcount tier and for
11+
# any new one added later.
12+
@idle_reaping_servers [
13+
Hyper.Node.Img.Mutable,
14+
Hyper.Node.Img.Server,
15+
Hyper.Node.Layer.Server
16+
]
17+
18+
for mod <- @idle_reaping_servers do
19+
test "#{inspect(mod)} is restart: :temporary so its idle :stop is not undone" do
20+
assert unquote(mod).child_spec([]).restart == :temporary
21+
end
22+
end
23+
end

0 commit comments

Comments
 (0)